Skip to content

Apache Airflow 3.3.1 wave — deserializer gadgets into the scheduler, dependency-vs-handler parser discrepancy, and secret-masker shape gaps

Advisory enrichment for the Apache Airflow 3.3.1 security release landed in the GitHub updated feed on October 2 23:1xZ (originally published Aug 10–12). Eleven advisories form three replayable operator clusters on any Airflow/orchestrator deployment: DAG-author-to-scheduler code execution through deserialization gadgets, an authorization bypass caused by two components parsing the same ID differently, and a family of secret-masking shape gaps that turn low-privilege read views into credential oracles.

Deserializer gadget enumeration — each fix covers one branch, not the deserializer

Airflow's serialization layer rebuilds objects from serialized blobs by importing class names from the blob itself. The advisories are a point-by-point inventory of which branch of the deserializer each fix closed — the durable rule is that the fix for one gadget is the map to the next:

  • Exception-node deserialization → arbitrary callable in the Scheduler and API server (GHSA-8prw-8m6x-8w6f / CVE-2026-58076, high): exception nodes are reconstructed by import_string() on a blob-supplied class name instantiated with blob-supplied args — subprocess.check_output, or builtins.eval on the builtins-prefixed variant. A DAG author places the payload in executor_config; it executes in the scheduler's normal reconstruction loop with no request involved, and in the API server on any authenticated read such as GET /api/v2/dags/{dag_id}/details. Both components hold the metadata DB credentials and the JWT signing secret. Explicit note in the advisory: this is a different sink from CVE-2026-33264 (trigger branch) — deployments that patched the earlier advisory are still exposed and must patch again.
  • Human-in-the-loop next_kwargs sweep (GHSA-q4c3-7575-55j2 / CVE-2026-67260, high): the 3.3.0 awaiting_input state is swept by the scheduler every 15 seconds, deserializing next_kwargs with no allow-list coverage; the DAG author controls that value via the task execution API. The default allowed_deserialization_classes setting does not cover this path.
  • Task SDK Callback reconstruction (GHSA-2qjv-p2gc-mjg2 / CVE-2026-67587, high): re-running the constructor imports the module named by the stored callback path; SyncCallback is itself an Airflow class, so it passes the default allow-list — tightening allowed_deserialization_classes does not help. Third gadget, same sweep, separate fix.
  • XCom JSON string literal instantiation (GHSA-fcmm-42r9-mmmm / CVE-2026-59242, medium): GET .../xcomEntries/{key}?deserialize=true skips the _check_forbidden_xcom_keys guard, so any authenticated user with XCom write+read can instantiate arbitrary airflow.* classes on the API server. Write-then-read-back-your-own-payload is the cheapest deserialization primitive there is — probe every deserialize=true-style flag.

Operator rules: 1. Grep every call site of the patched sink helper, not the patched file. Four separate advisories (CVE-2026-33264 trigger branch, 58076 exception branch, 67260 awaiting-input sweep, 67587 callback constructor) are four branches of the same "reconstruct from serialized blob" pattern. On any orchestrator/queue/workflow engagement, enumerate every deserialization entry point (executor configs, XCom, callbacks, checkpoint/HIL state) independently. 2. Fix ranges are attack maps. The advisories state plainly that upgrading for the sibling CVE leaves you exposed. If a deployment patched one of these, the remaining gadgets are live; version-fingerprint Airflow and diff against 3.3.1 per-advisory, not per-release. 3. Privilege floor: all four are DAG-author (write) or authenticated-read (XCom leg) — on shared Airflow instances, low-code users and Viewers with XCom access are the foothold class, and the sink is the component holding DB credentials + JWT signing secret.

Authorization-by-ID: dependency and handler parse the same value with different type parsers

  • Backfill API cross-DAG authorization bypass (GHSA-j4jc-cq9h-xrhr / CVE-2026-68968, high): the authorization dependency parsed the backfill_id path segment with Python int(), while the route handler parsed it as pydantic NonNegativeInt — which accepts values int() rejects (1.0 coerces to 1). FastAPI resolves dependencies before endpoint validation, so on backfill_id=1.0 (or similar) the two acted on different DAGs. A user with edit permission on any one DAG can read, pause, and cancel backfills of any other DAG, including moving another DAG's queued runs to failed. Backfill IDs are sequential, so target discovery is trivial.

Durable axis — parser-discrepancy authorization: whenever a security check and the business logic each re-parse the same input, any input form where the two parsers disagree (1.0, +1, 1, unicode digits, 0x1, trailing %00-class artifacts) splits authorization from effect. Sweep checklist for any FastAPI/Pydantic/JSON-schema stack: - list every route where auth/permission logic and the handler independently coerce a path/query ID; - probe each with coercion variants (1.0, 1, +1, locale digits) and diff which object was authorized vs which object was acted on (side effects on an owned target with a foreign-ID payload reveal the mismatch safely); - prefer deployments where authorization consumes the handler's already-validated value.

Secret-masker shape gaps — the masker is a shape-sensitive parser

Five advisories, one rule: the secret masker is code that makes its own decisions per value shape; enumerate shapes, not names.

  • Bulk endpoints log secrets in cleartext (GHSA-cjv4-7hp2-2x9h / CVE-2026-68969, medium): audit-log masking recognized only top-level request fields; PATCH /api/v2/variables and PATCH /api/v2/connections nest entities two levels deep → unmasked. Any authenticated user with audit-log read (not Variables/Connections read) recovers secrets verbatim — and Connection extra is logged unencrypted while the DB table encrypts it. The UI's Import Variables action posts to this endpoint, so routine operator imports wrote every secret to the log.
  • Config API leaks team-scoped secrets (GHSA-c56v-8w4g-3mfm / CVE-2026-65017, medium): the masker matched only base section+option names and never normalized team-prefixed sections → a Viewer with config-read reads a team's Celery broker URL, credentials included, while the equivalent global option is masked.
  • Dict-valued var.json rendered in the clear (GHSA-f643-3rc2-j27w / CVE-2026-59244, medium): the masker's isinstance(str) guard skipped dict values → secret-in-JSON-Variable shown cleartext in Rendered Templates.
  • Deep-nested list/tuple/set values unmasked (GHSA-g4qj-5vxv-wggf / CVE-2026-54183, medium): the recursion-depth fix (CVE-2026-42358) made only the dict walk unbounded; list/tuple/set entries beyond the depth limit stayed unmasked. (Advisory notes UI-only exposure — keep as a shape-taxonomy lesson, not a standalone finding.)
  • Cross-team secret resolution fall-through (GHSA-75mg-c62r-v95g / CVE-2026-68076 env-var backend, GHSA-qmrf-jrpr-rjx6 / CVE-2026-68871 Yandex Lockbox provider, medium): the env-var backend's cross-team guard only ran when no team scope was supplied and its name pattern could not match team names containing underscores → fall-through to a global read resolving another team's stored variable; POST /api/v2/connections/test then authenticates outward with the victim team's credentials without returning them — a credential-use oracle. The Lockbox backend resolves team-scoped IDs through the team-agnostic lookup on miss, handing over the foreign secret in full.

Operator rules: - Masker battery: for any "sensitive values are hidden" UI/API surface, test the same secret under: bulk/batch endpoint vs single endpoint, nested dict/list/tuple/set beyond naive depth limits, non-string value types, and scope-prefixed names (team-, tenant-, env- prefixes). Each spelling is an independent code path. - Test/connect endpoints are blind credential-use oracles: even where values are never returned, a test action that dials an attacker-reachable host with resolved credentials proves cross-scope resolution — owned callback, timing/status evidence only. - Audit logs are a secret store: on any Airflow-like platform with audit logging, check whether the log writer sees the same shape the masker was written for; bulk import actions are the highest-volume accidental-secret-to-log path.

Preconditions and validation boundaries

  • DAG-author legs require a low-privilege author/test-account DAG on a lab or authorized deployment; the API-server read legs (/details, XCom deserialize=true) need only an authenticated Viewer-class session. Verify the deployment's patched state per-advisory (3.3.1 consolidates; earlier point-fixes are partial).
  • Parser-discrepancy proofs: use a backfill you own as the authorized target and a second disposable DAG as the foreign target; assert the side effect lands on the foreign DAG only with coercion-shaped IDs. Never cancel/mutate production DAG runs.
  • Masker proofs: synthetic Variables/Connections with canary values in a lab deployment; owned callback host for connections/test; read only your canaries back. Never collect real LDAP/DB credentials, connection strings, or audit logs containing tenant secrets into report evidence — reference field names and masking decisions, redact values.

October 7 follow-up: Hydra proves the config-to-callable class has two bypasses — an unmigrated sink and an evadable blacklist (CVE-2026-106441 + CVE-2026-106442 / GHSA-c3wx-c55w-pxjq + GHSA-rqx7-p7vv-w7hr, hydra-core <1.3.6 / <1.4.0.dev9)

Same thesis as this page's import_string leg, proven twice in one framework. (1) Unmigrated sink: Hydra had already wrapped instantiate() _target_ resolution behind a target policy, but its Python-logging configuration path called logging.config.dictConfig() directly — handler class values and formatter/filter/handler/queue/listener () factories are import-and-call directives that never touched the policy. Controlling a Hydra logging config meant code execution even on builds patched for the earlier CVE. (2) Evadable blacklist: the _target_ deny-list itself fell to execution wrappers (timeit.timeit), executable deserialization (pickle.loads), generic dispatch/wrapper targets, target aliases, and callable-returning helpers that obscure the effective callable — name-based checks against a resolver that follows indirection. The vendor now says outright the blacklist "is not a complete security boundary" and ships an execution whitelist as the real fix.

  • Durable audit rules for every config-driven callable resolver (this completes the family with the Oct 7 Backstage markdown_extensions module:callable leg on the Oct 7 Backstage wave page): (a) enumerate every resolver sink in the product, not just the famous one — grep for dictConfig, getattr+call, entry-point loading, plugin import_strings, decorator factories; the protected path is a decoy once an unprotected sibling consumes the same config namespace; (b) treat any shipped blacklist as a challenge, not a boundary — build the bypass battery once (execution wrappers, loads-family, dispatchers, aliases, deferred return-callables, module:attr spellings, case/underscore variants) and replay it per target; the differential is deny-list rejection vs wrapper acceptance. Fixed-version fingerprint note: 1.3.6 still ships blacklist-only (best-effort by the vendor's own words), so "patched" ≠ whitelist posture; probe the wrapper battery to learn which posture a build actually runs. Proof stays lab-only: a marker file written by the wrapper callable, never a shell.

Tracked from this wave without publication

  • ~40 further Aug-dated Airflow/Red Hat re-surfacing advisories in the same updated-feed window are publication sync of already-processed/tracked items (the Oct 2 late-wave pattern); grep any late ID against docs/ before promoting.

October 7 20:2xZ follow-up: Hydra Optuna custom_search_space executes via get_method and mutable module-level blocklists self-defeat

Two more Hydra legs (published 20:23–20:24Z) extend the callable-resolver family on this page — same product as the earlier dictConfig pair, both config-to-execution:

  • hydra.sweeper.custom_search_space resolves a config-controlled dotted path through hydra.utils.get_method() and invokes it (GHSA-5jjj-9xc3-rm56 / CVE-2026-106440, 7.8, fixed 1.3.0): Hydra's low-level object-lookup helpers intentionally trust input and do NOT apply the execution policy that instantiate()/logging use — so an untrusted Optuna sweep config or command-line override selects any importable callable for execution in the controller process. On 1.4 dev builds this also bypassed an application-provided execution whitelist. Rule (completes the page's resolver-enumeration rule with its inverse): the safe resolver is a decoy when a raw sibling resolver consumes the same config namespace — instantiate() being policy-gated says nothing about get_method/get_object/get_static_method consumers; grep every lookup-helper call site and diff which apply policy. For ML-pipeline targets, sweep-config/CLI-override injection is the realistic untrusted input (job configs, notebook params), proven with a marker callable that writes a temp file.
  • Mutable module-level policy sets let config mutate its own blocklist (GHSA-mwj6-rfh8-7qf4 / CVE-2026-106439, fixed 1.3.7): _locate() resolves attributes on imported objects, so a sibling _target_ entry can resolve .discard and remove a blocked target from the live blocklist before the next entry instantiates it (sibling nodes process in insertion order against shared state). Durable bypass pattern for every deny-list-in-process-memory design: if the deny-list object is reachable through the same resolution mechanism it guards, insertion-ordered config = mutate-then-use. Audit test on any policy-gated resolver: can config name an attribute-chain ending in a set/dict mutation method on the policy object? Battery: .discard/.remove/.clear/.pop/update targets, ordering canary blocked-target after mutation step. Generalizes the page's "blacklist-as-challenge" rule from input-shape bypass to state-mutation bypass.