CVE
CVE-2026-33264
CWE
CWE-502
Affected Surface
- PyPI package `apache-airflow` before `3.3.0` where DAG authors are less trusted than the Scheduler or API Server processes
- Airflow Scheduler and API Server nodes that load serialized DAGs containing attacker-controlled trigger payloads
- Deployments that rely on permissive deserialization class allowlists or broad regular expressions under `[core] allowed_deserialization_classes` or `[core] allowed_deserialization_classes_regexp`
CVE-2026-33264 is one of the more important Python disclosure chains this week because it is not “just another unsafe deserialization” bug in a leaf library. It crosses a real trust boundary inside Airflow. A DAG author is often allowed to define workflows, triggers, and task behavior without being equivalent to a scheduler or webserver administrator. This bug broke that separation by deserializing attacker-controlled trigger state while the Scheduler and API Server loaded serialized DAGs.
The vendor’s own wording is precise: BaseSerialization.deserialize() could perform unrestricted import_string() on attacker-controlled class paths embedded in serialized DAG trigger fields. If your deployment assumes DAG-author code must not execute inside the API Server or Scheduler, this is a control-plane compromise path, not a cosmetic parser issue.
The vulnerable path was unnecessary work on the wrong process
The most useful thing in the Airflow fix is that it shows the bug existed in a path that should never have needed deserialization at all.
Before the fix, loading serialized start-trigger arguments looked like this:
def _decode_start_trigger_args(var: dict[str, Any]) -> StartTriggerArgs:
def deserialize_kwargs(key: str) -> Any:
if (val := var[key]) is None:
return None
return BaseSerialization.deserialize(val)
return StartTriggerArgs(
trigger_cls=var["trigger_cls"],
trigger_kwargs=deserialize_kwargs("trigger_kwargs"),
next_method=var["next_method"],
next_kwargs=deserialize_kwargs("next_kwargs"),
timeout=datetime.timedelta(seconds=var["timeout"]) if var["timeout"] else None,
)
That means a serialized DAG load on the Scheduler or API Server could take attacker-controlled JSON, rehydrate it into Python objects, and invoke the exact deserialization machinery that is supposed to stay behind tighter trust boundaries.
The fix is almost boring in the best possible way:
return StartTriggerArgs(
trigger_cls=var["trigger_cls"],
trigger_kwargs=var["trigger_kwargs"],
next_method=var["next_method"],
next_kwargs=var["next_kwargs"],
timeout=datetime.timedelta(seconds=var["timeout"]) if var["timeout"] else None,
)
In other words: stop deserializing those fields there at all.
That design correction tells you a lot about root cause. The bug was not merely “deserialization is dangerous.” It was that the wrong Airflow processes were doing deserialization they did not need to do.
Why import_string() makes this an RCE boundary
Airflow’s serializer can reconstruct richer Python objects than plain dicts and lists. In this case, the advisory calls out unrestricted import_string() of attacker-controlled class paths. That is the key escalation step:
serialized DAG
-> _decode_start_trigger_args()
-> BaseSerialization.deserialize(...)
-> import_string("attacker.controlled.module.Class")
-> class import / instantiation in Scheduler or API Server context
If DAG authors are less trusted than those control-plane processes, the security boundary is gone at that point.
This is why the affected-deployment question matters more than the generic CVSS label. In some single-tenant environments, the same operators may own both DAG code and the Airflow control plane. In many shared or platform-team-operated environments, they do not. The latter case is where this bug is dangerous: it lets workflow authors reach into processes that usually hold broader database access, scheduler authority, or web/API privileges.
The fix preserves trigger behavior by keeping raw JSON until the correct boundary
The PR description is valuable because it explains why removing deserialization here does not break legitimate trigger handling. The Triggerer still reads encrypted trigger kwargs from the Trigger database row rather than depending on DAG-deserialization side effects. The fix therefore changes who touches the data and when, not just the syntax of the payload.
The test additions in the patch make that explicit. After deserialization, trigger_kwargs now remain encoded:
assert task.start_trigger_args.trigger_kwargs == {
"__type": "dict",
"__var": {"delta": {"__type": "timedelta", "__var": 2.0}},
}
assert task.start_trigger_args.next_kwargs == {
"__type": "dict",
"__var": {"resume_after": {"__type": "timedelta", "__var": 5.0}},
}
That is what you want on the Scheduler or API Server: opaque, serialized data that does not cross into live Python objects until a trusted component truly needs it.
The patch also had to normalize encoded keys correctly
One subtle part of the fix is that once Airflow stopped eagerly deserializing these fields, it had to serialize the encoded marker keys consistently across Python versions. The patch introduced a helper to recursively normalize Encoding enum keys:
def stringify_encoding_keys(d: Any) -> Any:
if isinstance(d, dict):
return {
(k.value if isinstance(k, Encoding) else str(k)): stringify_encoding_keys(v)
for k, v in d.items()
}
if isinstance(d, list):
return [stringify_encoding_keys(i) for i in d]
if isinstance(d, tuple):
converted = [stringify_encoding_keys(i) for i in d]
if hasattr(d, "_fields"):
return type(d)(*converted)
return tuple(converted)
return d
That is important because the patch was not just “delete a deserialize call and leave.” Airflow had already coupled parts of trigger processing to the old behavior. Once the unnecessary deserialization was removed, the code had to preserve consistent marker-key semantics so the Triggerer could still reconstruct legitimate values later.
The engineering takeaway is useful beyond Airflow: once unsafe deserialization has leaked into the wrong layer, removing it often reveals hidden assumptions elsewhere in the stack.
Airflow 3.3.0 also tightened the regex-based allowlist semantics
The 3.3.0 release notes include another adjacent hardening change: [core] allowed_deserialization_classes_regexp now uses re.fullmatch() semantics instead of re.match(). The docs call out an example where a pattern such as:
airflow\.models\.Variable
previously matched unintended class names that merely started with that prefix, such as:
airflow.models.Variable_Malicious
That release-note item is not the same root cause as CVE-2026-33264, but it belongs in the same operational conversation. If you are already using deserialization class allowlists as a defense-in-depth boundary, sloppy prefix-matching weakens that control. Airflow now requires full-string matches, and operators relying on prefix-style regexes need to update them explicitly with .* where appropriate.
What is actually affected
According to the advisory data, the affected package boundary is simple:
PyPI package: apache-airflow
Affected versions: < 3.3.0
Fixed version: 3.3.0
The harder question is architectural:
- Do DAG authors have lower trust than Scheduler or API Server operators?
- Are serialized DAGs enabled on control-plane nodes that parse author-controlled trigger state?
- Do you allow custom classes through deserialization settings?
- Have you configured permissive deserialization regexes that were written with prefix-match assumptions?
If the answer to the first two questions is yes, you should treat this as a genuine privilege-boundary failure.
Detection and validation
Start by confirming the version and reviewing deserialization policy:
python3 -c "import airflow; print(airflow.__version__)"
pip show apache-airflow
rg -n \
"allowed_deserialization_classes|allowed_deserialization_classes_regexp" \
airflow.cfg config .env
Then review whether lower-trust DAG authors can define trigger-bearing tasks or custom references that traverse serialized DAG state:
rg -n "StartTriggerArgs|start_from_trigger|trigger_kwargs|next_kwargs|DeadlineReference" dags plugins
If your environment is multi-tenant or platform-operated, also answer these scoping questions:
Can DAG authors submit code or serialized state without separate control-plane access?
Do Scheduler or API Server nodes hold broader credentials than worker-only task code?
Are custom deserialization classes or regexes configured?
For incident response, preserve:
- the exact Airflow version
airflow.cfg- serialized DAG artifacts
- plugin definitions and custom class registrations
- audit history for recent DAG changes involving triggers or custom references
Remediation
The vendor fix is straightforward:
- upgrade to
apache-airflow3.3.0or later - restrict
[core] allowed_deserialization_classesto the narrowest feasible set - review
[core] allowed_deserialization_classes_regexpfor patterns that relied on old prefix-match behavior - reassess whether DAG authors should be treated as lower-trust principals relative to your control plane
If you run a shared Airflow service, this disclosure is also a policy reminder: “DAG authors can already run code somewhere” is not the same thing as “DAG authors should be able to run code in the Scheduler or API Server.”
That distinction is the whole bug. Airflow serialized trigger state crossed from author-controlled workflow data into higher-trust control-plane deserialization. The 3.3.0 fix works because it restores a simpler rule: keep those fields as raw JSON until the component that legitimately needs them reaches the correct execution boundary.
From research to remediation
Check whether this pattern exists in your codebase
Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.