Apache Airflow: Remote code execution in the Scheduler and the API server via unrestricted class reconstruction in Dag deserialization

A Dag author places an exception node in the serialized Dag representation through the executor_config field, whose class name is imported unchecked on deserialization and invoked with arguments from the same blob, running their code in the Scheduler and the API server.

Advisory ID: TP-2026-045
Product: Apache Airflow (data pipeline orchestration platform; the affected components are the Scheduler and the API server of Airflow 3)
Vulnerability type: Deserialization of untrusted data leading to code execution (CWE-502)
CVE: CVE-2026-58076
CVSS 3.1: 8.8 (High) · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H
Vendor advisory: Apache Airflow security announcement of 12 August 2026
Affected versions: Apache Airflow (apache-airflow) >= 3.0.0, < 3.3.1
Fixed in: Apache Airflow 3.3.1
Reported: 6 June 2026

Summary

Apache Airflow is a data pipeline orchestration platform. Airflow 3 stores every Dag as JSON in the serialized_dag table: the Scheduler rebuilds objects from it in its normal loop, and the API server does the same on any authenticated read of the Dag. Deserialization reconstructed exception nodes by calling import_string() on a class name taken from the stored blob and instantiating the result with arguments from the same blob, with no restriction on what may be imported. An operator's executor_config field runs through that same recursive dispatcher, so a Dag author can place a value there that causes an arbitrary callable to be imported and invoked, for example subprocess.check_output or os.system. The Scheduler and the API server are precisely the components in which, according to Airflow's own security model, Dag author code must never run, and both hold the metadata database credentials and the JWT signing secret. No non-default configuration is required. turingpoint verified the code execution in the API server live and reported the issue to the Apache Software Foundation, which fixed it in Airflow 3.3.1.

Root cause

BaseSerialization.deserialize dispatches the nodes of the serialized representation on the __type field (airflow-core/src/airflow/serialization/serialized_objects.py:626-708, line numbers as of Airflow 3.2.2). For the node types airflow_exc_ser and base_exc_ser, the branch takes the class name from the blob, resolves it with import_string() and calls the result with the positional and keyword arguments that come from the same blob (:671-675), with no allowlist. That the countermeasure already existed inside the same dispatcher is shown by the neighbouring branches: decode_timetable permits import_string only for names carrying the airflow.timetables. prefix plus a plugin registry, and _decode_priority_weight_strategy uses a strict registry with no import at all. The branch becomes reachable because executor_config is listed in _decorated_fields (:961) and deserialize_operator passes such fields through the full recursive cls.deserialize (:1152-1157), so any value placed there travels through the __type dispatcher. The reconstruction is triggered in the Scheduler with no request involved, and in the API server by an authenticated read such as GET /api/v2/dags/{dag_id}/details or GET /ui/structure/structure_data, for which read access to the Dag is sufficient. The trigger node of the same deserializer shares the root cause and was fixed separately as CVE-2026-33264, as the vendor advisory explicitly notes: deployments that upgraded only in response to that advisory remained exploitable through the exception branch.

Proof of Concept

# Exception node inside an operator's executor_config, as it ends up in the
# serialized Dag representation (table serialized_dag, column data):
{"__type": "airflow_exc_ser",
 "__var": {"__type": "dict",
           "__var": {"exc_cls_name": "os.system",
                     "args": ["sh -c 'id > /tmp/PROOF'"],
                     "kwargs": {"__type": "dict", "__var": {}}}}}

# Fires in the API server on any authenticated read of the Dag:
GET /api/v2/dags/<dag_id>/details
Authorization: Bearer <token of an account with read access to the Dag>

turingpoint verified the execution in the API server live on the unmodified apache/airflow:3.2.2 image (Docker Compose with the CeleryExecutor): after clearing the markers in all five containers and issuing one authenticated GET, the marker file with uid=1000(default) gid=0(root) was present in the airflow-apiserver container only, while the Scheduler, worker, triggerer and Dag processor held no marker file. Without a token the endpoint answers 401. That the same branch is also reached in the Scheduler, there without any request, is stated by the vendor advisory.

Impact

  • Arbitrary code execution in the API server process, which holds the metadata database credentials and the JWT signing secret and runs as uid=1000 gid=0(root) in the official image.
  • According to the vendor advisory the Scheduler reaches the same branch in its normal loop, so with no request and no authenticated caller involved.
  • Breaks the documented component isolation: Airflow's security model promises that Dag author code never runs in the Scheduler or the API server, other than through pre-registered plugins and providers.
  • In the API server an account with read access to the Dag is enough to trigger it, no administrator role is required; anonymous calls are rejected with 401.
  • No non-default configuration is required, and every version from 3.0.0 up to but excluding 3.3.1 is affected.

References

Is Something Like This in Your Software?

Our team found this vulnerability in the course of its work. Have your applications reviewed by the same specialists, with a penetration test from turingpoint.