# CVE-2026-33264 Patch Analysis — Bypass via `airflow_exc_ser`

## What the fix changes

The Apache Airflow 3.3.0 fix for CVE-2026-33264 (PR #68528, merged as part of the 3.3.0 release) removes the `DAT.BASE_TRIGGER` encode/decode path from `airflow/serialization/serialized_objects.py`:

- The `BaseTrigger` import is removed.
- The `isinstance(var, BaseTrigger)` serialization branch is removed.
- The `type_ == DAT.BASE_TRIGGER` deserialization branch is removed:
  ```python
  # Removed in 3.3.0
  elif type_ == DAT.BASE_TRIGGER:
      tr_cls_name, kwargs = cls.deserialize(var)
      tr_cls = import_string(tr_cls_name)
      return tr_cls(**kwargs)
  ```
- `StartTriggerArgs` no longer deserializes `trigger_kwargs` / `next_kwargs` through `BaseSerialization.deserialize()` (PR #66002); the raw JSON dict is kept instead.
- `TaskDeferred` is also dropped from the `AIRFLOW_EXC_SER` serialization tuple, because its only role was carrying a trigger over the AIP-44 Internal API.

## What assumption the fix relies on

The fix assumes that the only attacker-controllable `import_string()` sink reachable from serialized DAG content during `BaseSerialization.deserialize()` was the `DAT.BASE_TRIGGER` branch. By removing that branch, the developers believed that `BaseSerialization.deserialize()` no longer imports arbitrary attacker-controlled class names when loading DAGs.

The fix also relies on the fact that `trigger_kwargs` are no longer inflated back into Python objects during DAG loading, so even if an attacker controlled the `trigger_kwargs` dict, it would not be passed through `BaseSerialization.deserialize()`.

## What the fix does NOT cover

The fix does **not** cover the `DAT.AIRFLOW_EXC_SER` (and `DAT.BASE_EXC_SER`) deserialization branch in the same `BaseSerialization.deserialize()` method:

```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)   # <-- still ungated, arbitrary import
    else:
        exc_cls = import_string(f"builtins.{exc_cls_name}")
    return exc_cls(*args, **kwargs)
```

For `DAT.AIRFLOW_EXC_SER`, `exc_cls_name` is read directly from the serialized payload and passed to `import_string()` without any allowlist, subclass validation, or other gate. This means an attacker-controlled serialized exception class path still causes arbitrary module import (and module-level code execution) on the Scheduler / API Server process.

The `DAT.BASE_EXC_SER` branch only imports from `builtins`, so it is not a direct arbitrary-import path, but it still demonstrates the same incomplete fix assumption: the fix only removed one type-specific branch instead of auditing all branches that call `import_string()`.

Also, the fix does not change the fact that the new SDK serde (`airflow.sdk.serde`) uses `allowed_deserialization_classes` while the legacy `BaseSerialization` path in `airflow/serialization/serialized_objects.py` still ignores that allowlist. Therefore, the `allowed_deserialization_classes` defense-in-depth mitigation referenced in the CVE does **not** protect the legacy `airflow_exc_ser` path.

## Behavior before and after the fix

**Before the fix (3.2.2):**
- `base_trigger` payload: `import_string()` executes the attacker-controlled module → RCE.
- `airflow_exc_ser` payload: `import_string()` executes the attacker-controlled module → RCE.

**After the fix (3.3.0):**
- `base_trigger` payload: raises `TypeError: Invalid type base_trigger in deserialization.` → blocked.
- `airflow_exc_ser` payload: `import_string()` still executes the attacker-controlled module → RCE remains possible.

This divergence is demonstrated by the variant reproduction script in `bundle/vuln_variant/reproduction_steps.sh`.

## Fix completeness assessment

The fix is **incomplete**. It correctly removes the dead `base_trigger` path but fails to apply the same protection to the co-located `airflow_exc_ser` branch. Both branches use the same `import_string()` sink with attacker-controlled input from serialized DAG content, and both cross the same Airflow trust boundary (DAG-author code must not execute in the Scheduler/API Server process).

The complementary fix referenced by the maintainers (PR #68511, "Restrict exception-node deserialization to known classes without importing the stored name") has not been applied to the 3.3.0 release or to the current `main` branch at the time of testing. As a result, the `airflow_exc_ser` branch remains an ungated arbitrary import path.

## Target threat model scope

Apache Airflow's security model treats the Scheduler, API Server, and DAG File Processor as the trusted control plane. DAG-author supplied files are parsed by the DAG File Processor and the resulting serialized DAG is loaded by the Scheduler and API Server. Because DAG-author code must not execute in the Scheduler/API Server process, any path in the serialization layer that imports a class from attacker-controlled serialized data crosses the trust boundary. The CVE and the fix explicitly acknowledge this boundary. The `airflow_exc_ser` bypass operates within the same trust boundary and therefore falls within the target's stated security scope.
