{"repro_id":"REPRO-2026-00277","version":6,"title":"Apache Airflow DAG author RCE via unrestricted import_string() in BaseSerialization.deserialize()","repro_type":"security","status":"published","severity":"critical","description":"Apache Airflow prior to 3.3.0 is vulnerable to remote code execution when loading serialized DAG content. A bug in BaseSerialization.deserialize() allows unrestricted import_string() of attacker-controlled class paths, so a DAG author can embed a malicious trigger or class reference that causes execution on the Scheduler/API Server process. This crosses the Airflow trust boundary because DAG-author code must not execute in those processes. Affected versions are all before 3.3.0; users should upgrade to 3.3.0 or later. Defense-in-depth mitigation: restrict [core] allowed_deserialization_classes to a narrow allowlist when DAG-author trust is limited.","root_cause":"# CVE-2026-33264 — Root Cause Analysis\n\n## Summary\n\nApache Airflow prior to 3.3.0 allows a DAG author to execute arbitrary code on the Scheduler or API Server process during DAG deserialization. When the scheduler/API server loads serialized DAG content, `BaseSerialization.deserialize()` in `airflow/serialization/serialized_objects.py` unconditionally calls `import_string()` on attacker-controlled class paths embedded in serialized objects such as `BaseTrigger`. Importing an arbitrary Python module executes its module-level code, which crosses the trust boundary between DAG-author code and the scheduler/API server. Apache Airflow 3.3.0 removes this behavior and no longer deserializes `base_trigger` payloads through `BaseSerialization.deserialize()`.\n\n## Impact\n\n- **Package/component affected:** Apache Airflow (`airflow.serialization.serialized_objects.BaseSerialization.deserialize()`)\n- **Affected versions:** All versions before 3.3.0 (reproduced against 3.2.2)\n- **Fixed versions:** 3.3.0 and later\n- **Risk level:** Critical\n- **Consequences:** A malicious DAG author can embed a class path that points to a module under their control. When the scheduler or API server deserializes the DAG, the module is imported and its top-level code executes in that process, leading to remote code execution in a trusted Airflow component.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Remote code execution in the Scheduler/API Server via attacker-controlled class paths in serialized DAG content.\n- **Reproduced impact from this run:** Code execution demonstrated. A crafted `base_trigger` payload caused the vulnerable Airflow 3.2.2 installation to import an attacker-controlled module and execute its module-level code, which wrote a marker file (`PWNED`). The fixed 3.3.0 installation rejected the same payload with `TypeError: Invalid type base_trigger in deserialization.` and did not perform the import or write the marker.\n- **Parity:** `full` for the library-level claim. The harness directly invokes `BaseSerialization.deserialize()`, the same path the scheduler/API server uses when loading serialized DAGs, so the root cause and impact are matched.\n- **Not demonstrated:** No scheduler or API server process was actually started; the proof is a realistic harness that exercises the deserialization entry point directly. A full end-to-end DAG-file → scheduler parsing loop would further strengthen the claim but is not required for the submitted `library_api` surface.\n\n## Root Cause\n\nIn Airflow 3.2.2, `BaseSerialization.deserialize()` contains a branch that handles `DAT.BASE_TRIGGER`:\n\n```python\nelif type_ == DAT.BASE_TRIGGER:\n    tr_cls_name, kwargs = cls.deserialize(var)\n    tr_cls = import_string(tr_cls_name)\n    return tr_cls(**kwargs)\n```\n\nThe `tr_cls_name` value is read directly from the serialized payload (`__var[0]`), and `import_string()` is called without any allowlist check. This means any importable class path supplied by the DAG author is imported during deserialization. Because Python module import executes top-level code, an attacker-controlled module runs in the scheduler/API server process.\n\nIn Airflow 3.3.0, the `DAT.BASE_TRIGGER` branch has been removed from `BaseSerialization.deserialize()`, so the same payload falls through to the generic `else` branch and raises `TypeError: Invalid type base_trigger in deserialization.` before any import occurs. Trigger objects are now deserialized through a more restricted path that does not expose arbitrary `import_string()` behavior to DAG authors.\n\n## Reproduction Steps\n\n1. Run `bundle/repro/reproduction_steps.sh`.\n2. The script detects the prepared project cache (`/data/pruva/project-cache/03610ec6-e6fb-4086-9681-103dd9199da6/repo`) and uses two pre-built virtualenvs:\n   - `airflow_product_venv` — Apache Airflow 3.2.2 (vulnerable)\n   - `airflow_3.3.0_cve_2026_33264_venv` — Apache Airflow 3.3.0 (fixed)\n3. The script writes `bundle/repro/deserialize_harness.py`, which:\n   - Creates a temporary attacker-controlled Python module (`evil_trigger.py`) whose top-level code writes a marker file (`PWNED`) when imported.\n   - Builds a serialized `base_trigger` payload with `__classname__` pointing to `evil_trigger.EvilTrigger`.\n   - Calls `BaseSerialization.deserialize(payload)` from `airflow.serialization.serialized_objects`.\n   - Records whether the marker file was created and whether an exception was raised.\n4. The script runs the harness against the vulnerable venv and the fixed venv.\n\n### Expected evidence\n\n- **Vulnerable (3.2.2):** The harness prints `MODULE-LEVEL-EXECUTED`, the marker file exists, and `exception_type` is `null`.\n- **Fixed (3.3.0):** The marker file does not exist and the harness raises `TypeError: Invalid type base_trigger in deserialization.`.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full console output from both attempts, including version detection and verdict.\n- `bundle/logs/vulnerable_result.json` — structured result for the vulnerable run (Airflow 3.2.2, marker created, no exception).\n- `bundle/logs/fixed_result.json` — structured result for the fixed run (Airflow 3.3.0, no marker, `TypeError`).\n- `bundle/repro/deserialize_harness.py` — the harness that constructs the malicious payload and calls `BaseSerialization.deserialize()`.\n- `bundle/repro/runtime_manifest.json` — runtime manifest describing the entry point, target path, and proof artifacts.\n\nKey excerpt from `bundle/logs/reproduction_steps.log`:\n\n```\nVulnerable Airflow version: 3.2.2\nFixed Airflow version: 3.3.0\n...\nMODULE-LEVEL-EXECUTED\n[vulnerable] marker_exists_after=True exception=None\n...\n[fixed] marker_exists_after=False exception=TypeError\n[fixed] TypeError: Invalid type base_trigger in deserialization.\nConfirmed: true\n```\n\n## Recommendations / Next Steps\n\n- **Upgrade:** Move to Apache Airflow 3.3.0 or later, where `BaseSerialization.deserialize()` no longer handles `base_trigger` and therefore no longer imports arbitrary attacker-controlled classes.\n- **Defense-in-depth:** If DAG-author trust is limited, restrict `[core] allowed_deserialization_classes` to a narrow allowlist of known safe classes. Review serialized DAG content before it is loaded by the scheduler or API server.\n- **Testing:** Add negative tests that assert unknown/attacker-controlled class names in `base_trigger` payloads are rejected, and verify that trigger deserialization does not call `import_string()` with untrusted input.\n\n## Additional Notes\n\n- The reproduction script is idempotent: it creates isolated temporary directories for the attacker module and `AIRFLOW_HOME`, and it deletes/removes the marker before each run. Two consecutive executions both produced the same vulnerable/fixed divergence.\n- The harness uses the real `BaseSerialization.deserialize()` implementation from the installed Apache Airflow packages, not a mock or reimplementation.\n- The proof relies on the same trust-boundary crossing described in the CVE: attacker-controlled serialized DAG content causes import and execution in the scheduler/API server process during deserialization.\n","cve_id":"CVE-2026-33264","cwe_id":"CWE-502","source_url":"https://github.com/apache/airflow","package":{"name":"apache/airflow","ecosystem":"github","affected_versions":"< 3.3.0","fixed_version":"3.3.0"},"reproduced_at":"2026-07-09T18:06:21.729525+00:00","duration_secs":972.0,"tool_calls":147,"handoffs":2,"total_cost_usd":1.597662899999999,"agent_costs":{"hypothesis_generator":0.08093195,"judge":0.355207,"repro":0.13158240000000002,"support":0.05028286999999999,"vuln_variant":0.97965868},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/kimi-k2p7-code":0.08093195},"judge":{"gpt-5.5":0.355207},"repro":{"accounts/fireworks/models/kimi-k2p7-code":0.13158240000000002},"support":{"accounts/fireworks/models/kimi-k2p7-code":0.05028286999999999},"vuln_variant":{"accounts/fireworks/models/kimi-k2p7-code":0.97965868}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-09T18:06:47.149520+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":9083,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7117,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":8728,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":11459,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":7408,"category":"other"},{"path":"bundle/artifact_promotion_report.json","filename":"artifact_promotion_report.json","size":7426,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1081,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1286,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":750,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":667,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":2678,"category":"log"},{"path":"bundle/logs/vulnerable_result.json","filename":"vulnerable_result.json","size":418,"category":"other"},{"path":"bundle/logs/fixed_result.json","filename":"fixed_result.json","size":464,"category":"other"},{"path":"bundle/repro/deserialize_harness.py","filename":"deserialize_harness.py","size":3064,"category":"script"},{"path":"bundle/logs/vuln_variant_reproduction.log","filename":"vuln_variant_reproduction.log","size":2861,"category":"log"},{"path":"bundle/logs/vuln_variant_fixed_result.json","filename":"vuln_variant_fixed_result.json","size":506,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":5370,"category":"documentation"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1125,"category":"other"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2642,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":787,"category":"other"},{"path":"bundle/logs/vuln_variant_vulnerable_result.json","filename":"vuln_variant_vulnerable_result.json","size":511,"category":"other"},{"path":"bundle/vuln_variant/deserialize_harness_exc_ser.py","filename":"deserialize_harness_exc_ser.py","size":3166,"category":"script"}]}