## Summary

Apache Airflow before 3.3.0 deserializes attacker-controlled serialized DAG fields with `BaseSerialization.deserialize()` without sufficiently constraining class-path imports. In the reproduced path, a malicious serialized DAG contains a `__type="base_trigger"` value whose class path points to an attacker-controlled module. When the real Airflow API server handles a UI endpoint that loads the serialized DAG (`/ui/grid/structure/{dag_id}`), Airflow 3.2.2 reaches `SerializedDagModel.dag -> DagSerialization.from_dict -> BaseSerialization.deserialize()` and calls `import_string()` on that class path, executing module-level code inside the API server process. Airflow 3.3.0 no longer imports the same `base_trigger` payload and fails closed without executing the attacker module.

## Impact

- Package/component affected: `apache-airflow`, specifically serialized DAG deserialization in `airflow.serialization.serialized_objects.BaseSerialization` as reached by API server/UI serialized DAG loading.
- Affected versions: Apache Airflow before 3.3.0, reproduced here with Airflow 3.2.2.
- Fixed version: Apache Airflow 3.3.0.
- Risk level and consequences: critical/important security-boundary violation. A DAG author who can cause malicious serialized DAG data to be loaded can execute Python code in the Scheduler/API Server process, not merely in an isolated task execution context. This can expose scheduler/API credentials, metadata database access, local files, and any privileges available to those Airflow control-plane processes.

## Impact Parity

- Disclosed/claimed maximum impact: code execution in Scheduler/API Server through unrestricted class-path import during serialized DAG deserialization.
- Reproduced impact from this run: code execution in the real Airflow API server process. The proof module writes `api_server_rce_marker.txt` at import time and records the API server PID. The vulnerable endpoint returns HTTP 200 after deserializing the DAG, and the marker content shows it was written by the API server process.
- Parity: `full`.
- Not demonstrated: the proof stops at controlled file creation rather than running a shell command or exfiltrating secrets. This is sufficient to demonstrate arbitrary Python module import-time code execution, but no post-exploitation chain was attempted.

## Root Cause

In Airflow 3.2.2, `BaseSerialization.deserialize()` contains a `DAT.BASE_TRIGGER` case that deserializes `(trigger_class_path, kwargs)`, calls `import_string(trigger_class_path)`, and instantiates the returned class. The serialized class path is attacker-controlled serialized DAG data. When a server-side process such as the API server loads serialized DAG rows from the metadata database, decorated DAG fields are passed through `BaseSerialization.deserialize()`, allowing the import to occur in the API server process.

The reproduction stores a malicious serialized DAG with `dag.default_args = {"__type": "base_trigger", "__var": ["evil_trigger_module.EvilTrigger", ...]}`. Airflow 3.2.2 accepts this type and imports `evil_trigger_module`, whose module-level code writes a marker file. Airflow 3.3.0 rejects the historical `base_trigger` type during deserialization and therefore does not import the attacker module.

Known fix references from the advisory:
- https://github.com/apache/airflow/pull/66002
- https://github.com/apache/airflow/pull/68528

## Reproduction Steps

1. Run `bundle/repro/reproduction_steps.sh`.
2. The script:
   - Reuses the prepared project cache at `<project_cache_dir>/repo` when available.
   - Uses/reuses an Airflow 3.2.2 virtualenv for the vulnerable product and an Airflow 3.3.0 virtualenv for the fixed control.
   - For each attempt, initializes a fresh Airflow metadata database, creates an attacker-controlled module in the DAG folder, and seeds a serialized DAG row containing a malicious `base_trigger` serialized value.
   - Starts the real `airflow api-server` on localhost, waits for `/api/v2/monitor/health`, then sends an HTTP request to `/ui/grid/structure/{dag_id}?offset=0&limit=100`.
   - Runs two vulnerable attempts and two fixed attempts.
3. Expected evidence of reproduction:
   - Airflow 3.2.2 attempts: HTTP endpoint is reached, returns 200, and `api_server_rce_marker.txt` is created with `import-time code execution in Airflow API server; pid=<api-server-pid>`.
   - Airflow 3.3.0 attempts: HTTP endpoint is reached but returns a deserialization error (HTTP 500) and no marker file is created.

## Evidence

Primary runtime evidence is written under `bundle/logs/` and `bundle/artifacts/` by `bundle/repro/reproduction_steps.sh`.

Key files:
- `bundle/logs/reproduction_steps.log` — full script output, including both clean successful runs in this session.
- `bundle/logs/attempt_3.2.2_1.json` and `bundle/logs/attempt_3.2.2_2.json` — vulnerable product attempts.
- `bundle/logs/attempt_3.3.0_1.json` and `bundle/logs/attempt_3.3.0_2.json` — fixed product attempts.
- `bundle/artifacts/airflow_api_3.2.2_1/api_server_rce_marker.txt` and `bundle/artifacts/airflow_api_3.2.2_2/api_server_rce_marker.txt` — vulnerable API-server import-time execution markers.
- `bundle/artifacts/airflow_api_*/airflow_api_server.log` — API server startup and HTTP access logs.
- `bundle/artifacts/airflow_api_*/endpoint_headers.txt` and `bundle/artifacts/airflow_api_*/endpoint_response.json` — captured HTTP endpoint responses.
- `bundle/repro/runtime_manifest.json` — structured runtime evidence manifest.
- `bundle/repro/validation_verdict.json` — structured verdict.

Representative vulnerable evidence from the second verified run:
- `attempt_3.2.2_1.json`: `service_started=true`, `healthcheck_passed=true`, `target_reached=true`, `http_status="200"`, `import_side_effect_observed=true`.
- Marker content: `import-time code execution in Airflow API server; pid=17188`.
- API server access log shows `method=GET path=/ui/grid/structure/cve_2026_33264_probe_3_2_2_1 ... status_code=200`.

Representative fixed-control evidence:
- `attempt_3.3.0_1.json` and `attempt_3.3.0_2.json`: `service_started=true`, `healthcheck_passed=true`, `target_reached=true`, `http_status="500"`, `import_side_effect_observed=false`.
- Fixed response excerpt: `An error occurred while trying to deserialize Dag`, with no `api_server_rce_marker.txt` created.

Environment details captured:
- Vulnerable Airflow: 3.2.2 loaded from `<project_cache_dir>/repo/airflow_product_venv`.
- Fixed Airflow: 3.3.0 loaded from `<project_cache_dir>/repo/airflow_3.3.0_cve_2026_33264_venv`.
- Product entrypoint: `airflow api-server` (uvicorn) reached over localhost HTTP.

## Recommendations / Next Steps

- Upgrade `apache-airflow` to 3.3.0 or later.
- Keep `[core] allowed_deserialization_classes` restricted to a narrow allowlist as defense in depth, especially in deployments where DAG authors are not fully trusted.
- Add regression tests that insert or load serialized DAG payloads containing historical/unknown serialized types such as `base_trigger` and assert that API server and scheduler code reject them without importing user-controlled modules.
- Audit other serialized DAG fields that call generic deserialization helpers to ensure no other user-controlled class path reaches `import_string()` in control-plane processes.

## Additional Notes

- Idempotency confirmation: `bundle/repro/reproduction_steps.sh` was executed twice consecutively and completed successfully both times after the fixed-control payload-generation correction.
- The proof uses a local HTTP API server and a local SQLite metadata database, but it exercises the real Airflow API server product path and a real HTTP endpoint rather than calling `BaseSerialization.deserialize()` directly.
- The seeded malicious serialized DAG row represents the advisory scenario: attacker-controlled serialized DAG data containing a class path is loaded by the API server. The exploit module is placed on the API server's DAG-folder `PYTHONPATH`, matching deployments where control-plane processes can import DAG-bundle modules.
