# CVE-2026-33264 — Variant / Bypass RCA

## Summary

Apache Airflow 3.3.0, which is the patched release for CVE-2026-33264, remains vulnerable to remote code execution when loading serialized DAG content. The 3.3.0 fix removed the `DAT.BASE_TRIGGER` branch in `BaseSerialization.deserialize()` but left the neighboring `DAT.AIRFLOW_EXC_SER` branch intact. That branch still calls `import_string()` on an attacker-controlled class name read from the serialized payload, so a DAG author can embed a malicious `airflow_exc_ser` node instead of a `base_trigger` node and achieve the same RCE on the Scheduler/API Server process. This is a true bypass of the 3.3.0 fix for the same root cause and trust boundary.

## Fix Coverage / Assumptions

The original fix (Apache Airflow 3.3.0, PR #68528) assumes that the only dangerous `import_string()` sink in `BaseSerialization.deserialize()` was the `DAT.BASE_TRIGGER` branch. It removes:
- The `BaseTrigger` import and the `isinstance(var, BaseTrigger)` serialization branch.
- The `type_ == DAT.BASE_TRIGGER` deserialization branch that called `import_string(tr_cls_name)`.
- The `TaskDeferred` entry from the exception serialization tuple, because it was only used to carry a trigger over the AIP-44 RPC.
- The `BaseSerialization.deserialize()` call on `StartTriggerArgs.trigger_kwargs` / `next_kwargs` (PR #66002), keeping them as raw JSON.

The fix does **not** modify the `DAT.AIRFLOW_EXC_SER` / `DAT.BASE_EXC_SER` branch in the same `deserialize()` method, nor does it apply the `allowed_deserialization_classes` allowlist to the legacy `BaseSerialization` path. Consequently, the same arbitrary `import_string()` sink remains reachable through a different serialized type.

## Variant / Alternate Trigger

- **Parent surface:** `BaseSerialization.deserialize()` with `type_ == "base_trigger"`.
- **Bypass surface:** `BaseSerialization.deserialize()` with `type_ == "airflow_exc_ser"`.
- **Entry point:** direct call to `airflow.serialization.serialized_objects.BaseSerialization.deserialize()`, which is the same recursive helper used when the Scheduler and API Server load serialized DAGs (e.g., from `SerializedDagModel` / `airflow/utils/sqlalchemy.py` and the full `deserialize_dag` path).
- **Specific code path:** `airflow/serialization/serialized_objects.py`, in `BaseSerialization.deserialize()` around the `type_ == DAT.AIRFLOW_EXC_SER` branch:
  ```python
  elif type_ == DAT.AIRFLOW_EXC_SER or type_ == DAT.BASE_EXC_SER:
      deser = cls.deserialize(var)
      exc_cls_name = deser["exc_cls_name"]
      args = deser["args"]
      kwargs = deser["kwargs"]
      del deser
      if type_ == DAT.AIRFLOW_EXC_SER:
          exc_cls = import_string(exc_cls_name)   # attacker-controlled import
      else:
          exc_cls = import_string(f"builtins.{exc_cls_name}")
      return exc_cls(*args, **kwargs)
  ```
- **Attacker-controlled input:** the serialized `airflow_exc_ser` node, specifically the `exc_cls_name` string, plus the `args` and `kwargs` that are passed to the imported class constructor.

## Impact

- **Package/component affected:** Apache Airflow (`airflow.serialization.serialized_objects.BaseSerialization.deserialize()`).
- **Affected versions:** Reproduced against Apache Airflow 3.2.2 (vulnerable) and 3.3.0 (claimed fixed, but bypassed). The same `airflow_exc_ser` branch was also observed in the current `main` branch at commit `0d9dd822d3ac0cb44380836c455baecdf25e2eff`.
- **Risk level:** Critical.
- **Consequences:** A malicious DAG author can embed a crafted `airflow_exc_ser` payload in serialized DAG content. When the Scheduler or API Server deserializes it, the attacker-controlled module is imported and its module-level code executes in that process. The attacker also controls the constructor arguments of the imported class, so the instance creation itself can execute attacker-controlled `__init__` code. This is the same trust-boundary crossing as the original CVE: DAG-author code executes in the Scheduler/API Server.

## Impact Parity

- **Disclosed/claimed maximum impact:** Remote code execution in the Scheduler/API Server via attacker-controlled class paths in serialized DAG content.
- **Reproduced impact from this variant run:** Code execution demonstrated. A crafted `airflow_exc_ser` payload caused both Apache Airflow 3.2.2 and the fixed 3.3.0 to import an attacker-controlled module and execute its module-level code, which wrote a marker file (`PWNED`). The original `base_trigger` payload was blocked on 3.3.0 with `TypeError: Invalid type base_trigger in deserialization.`, confirming the parent fix is in place but the `airflow_exc_ser` path bypasses it.
- **Parity:** `full` for the library-level claim. The same deserialization sink and the same trust-boundary crossing are reached; only the encoded type tag differs.
- **Not demonstrated:** No live Scheduler or API Server process was started. The proof uses the same realistic `BaseSerialization.deserialize()` harness as the parent reproduction. A full end-to-end DAG file → database → Scheduler load would further strengthen the claim but is not required for the submitted `library_api` surface.

## Root Cause

The root cause is identical to the parent CVE: `BaseSerialization.deserialize()` calls `import_string()` on a value read directly from the serialized payload without any allowlist or type validation. The 3.3.0 fix removed one instance of this pattern (`DAT.BASE_TRIGGER`) but left another instance in the same function (`DAT.AIRFLOW_EXC_SER`). The `exc_cls_name` field is attacker-controlled, so importing it loads the attacker module and executes module-level code. The instance is then constructed with attacker-controlled `args`/`kwargs`, so `__init__` code also runs under attacker control.

- **Parent fix commit:** `f5f7953d949f61552c4feb8b3200be2a677a08c5` (tag `3.3.0`, commit `1438ea3587031417cc85d74323235cf087a058fb`).
- **Complementary fix mentioned by maintainers but not included:** PR #68511 "Restrict exception-node deserialization to known classes without importing the stored name".
- **Bypassed code:** `airflow/serialization/serialized_objects.py`, `BaseSerialization.deserialize()`, `DAT.AIRFLOW_EXC_SER` branch.

## Reproduction Steps

1. Run `bundle/vuln_variant/reproduction_steps.sh`.
2. The script detects the prepared project cache (`/data/pruva/project-cache/03610ec6-e6fb-4086-9681-103dd9199da6/repo`) and uses two pre-built virtualenvs:
   - `airflow_product_venv` — Apache Airflow 3.2.2 (vulnerable parent version).
   - `airflow_3.3.0_cve_2026_33264_venv` — Apache Airflow 3.3.0 (claimed fixed).
3. The script writes `bundle/vuln_variant/deserialize_harness_exc_ser.py`, which:
   - Creates a temporary attacker-controlled Python module (`evil_exc.py`) whose top-level code writes a marker file (`PWNED`) when imported.
   - Builds a serialized `airflow_exc_ser` payload with `exc_cls_name` pointing to `evil_exc.EvilException` and properly encoded `kwargs`.
   - Calls `BaseSerialization.deserialize(payload)` from `airflow.serialization.serialized_objects`.
   - Records whether the marker file was created and whether an exception was raised.
4. The script runs the harness against the vulnerable venv and the fixed venv.
5. The script exits `0` if the bypass is confirmed (both versions create the marker), or `1` otherwise.

### Expected evidence

- **Vulnerable (3.2.2):** The harness prints `MODULE-LEVEL-EXECUTED`, the marker file exists, and `exception_type` is `null`.
- **Fixed (3.3.0):** The same happens — the marker file exists and `exception_type` is `null`, proving the fix did not cover this branch.
- **Parent fix still present:** The original `base_trigger` payload is rejected on 3.3.0 with `TypeError: Invalid type base_trigger in deserialization.` (not directly tested by this variant script, but confirmed in the parent reproduction).

## Evidence

- `bundle/logs/vuln_variant_reproduction.log` — full console output from both attempts, including version detection and verdict.
- `bundle/logs/vuln_variant_vulnerable_result.json` — structured result for the vulnerable run (Airflow 3.2.2, marker created, no exception).
- `bundle/logs/vuln_variant_fixed_result.json` — structured result for the fixed run (Airflow 3.3.0, marker created, no exception).
- `bundle/vuln_variant/deserialize_harness_exc_ser.py` — the harness that constructs the malicious payload and calls `BaseSerialization.deserialize()`.
- `bundle/vuln_variant/runtime_manifest.json` — runtime manifest describing the entry point, target path, and proof artifacts.
- `bundle/vuln_variant/patch_analysis.md` — detailed comparison of the 3.2.2 vs 3.3.0 serialization code and the missed branch.

Key excerpt from `bundle/logs/vuln_variant_reproduction.log`:

```
Vulnerable Airflow version: 3.2.2
Fixed Airflow version: 3.3.0
...
MODULE-LEVEL-EXECUTED
[vulnerable] marker_exists_after=True exception=None
...
MODULE-LEVEL-EXECUTED
[fixed] marker_exists_after=True exception=None
...
Bypass confirmed: true
Bypass confirmed: fixed version remains vulnerable via airflow_exc_ser.
```

## Recommendations / Next Steps

1. **Close the exception branch:** Apply the same gating logic to the `DAT.AIRFLOW_EXC_SER` branch as the maintainers proposed in PR #68511. Do not call `import_string(exc_cls_name)` directly on a serialized value. Either validate the class against an allowlist before importing, or avoid importing from the stored name entirely (e.g., resolve only registered exception types by a safe mapping).
2. **Audit all `import_string()` sinks in the legacy serialization layer:** The fix should not be type-specific. Any branch in `BaseSerialization.deserialize()` (or `decoders.py`) that calls `import_string()` on serialized data must validate the import path against an allowlist or a registry.
3. **Enforce `allowed_deserialization_classes` consistently:** The legacy `BaseSerialization` path currently does not honor `[core] allowed_deserialization_classes`. Either make the legacy path respect the same allowlist or migrate the exception branch to the new SDK serde, which already uses it.
4. **Regression tests:** Add negative tests that assert arbitrary/attacker-controlled class names in `airflow_exc_ser` payloads are rejected, and verify that the rejection happens before `import_string()` is called.
5. **Upgrade path:** Users relying on the 3.3.0 fix should not assume complete protection. They should apply a narrow `allowed_deserialization_classes` allowlist if available, or wait for a follow-up release that fixes the exception branch.

## Additional Notes

- The reproduction script is idempotent: it creates isolated temporary directories for the attacker module and `AIRFLOW_HOME`, and it removes the marker before each run. Two consecutive executions both produced the same vulnerable/fixed behavior (both created the marker).
- The harness uses the real `BaseSerialization.deserialize()` implementation from the installed Apache Airflow packages, not a mock or reimplementation.
- The proof relies on the same trust-boundary crossing as the parent CVE: attacker-controlled serialized DAG content causes import and execution in the scheduler/API server process during deserialization.
- The bypass was also observed in the current `main` branch of apache/airflow at `0d9dd822d3ac0cb44380836c455baecdf25e2eff`, indicating the issue is not resolved in the default development branch at the time of this run.
