Apache Airflow가 직렬화된 DAG을 불러올 때 트리거 클래스 경로를 검증 없이 import해 스케줄러/API 서버에서 RCE가 발생하는 문제 (CVE-2026-33264)
| 항목 | 내용 |
|---|---|
| CVE ID | CVE-2026-33264 |
| 영향 소프트웨어 | Apache Airflow (apache-airflow) 3.3.0 미만 |
| CVSS v3.1 | 9.8 (Critical, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H) |
| 공개일 | 2026-07-07 |
| 발견자 | Ziyu Lin, bugbunny.ai(자동화 도구), intadd(@intadd), K |
| 수정 담당 | Amogh Desai(@amoghrajesh), Jarek Potiuk (Apache Airflow PMC) |
| 수정 PR | #66002 "Do not deserialize trigger_kwargs when loading serialized DAGs" (병합 2026-05-07), #68528 "Remove dead AIP-44 trigger-over-BaseSerialization path" (병합 2026-06-17) |
Airflow는 사용자가 작성한 DAG(Python 코드로 정의된 워크플로) 파일을 스케줄러가 반복해서
직접 실행하지 않도록, DAG의 구조를 한 번 파싱해 JSON류의 "직렬화된 DAG"으로 저장해 두고
스케줄러/API 서버는 이 직렬화된 형태만 읽는다. 이 설계의 핵심 전제는 "DAG 작성자가 짠
코드는 워커(Worker)나 트리거러(Triggerer)에서만 실행되고, 스케줄러·API 서버 같은 더
높은 신뢰 등급의 프로세스에서는 절대 실행되지 않는다"는 **신뢰 경계(trust boundary)**다.
CVE-2026-33264는 바로 이 경계가 깨진 사례다. "지연 실행(defer)" 기능을 쓰는 태스크의
직렬화된 정보(trigger_kwargs) 안에, BaseSerialization.deserialize()가 이해하는
특수 인코딩(__type 태그)을 붙여 임의의 파이썬 클래스 경로를 심어 두면, 스케줄러나 API
서버가 그 DAG을 불러오는 순간 import_string()으로 그 경로를 그대로 import해 인스턴스화
해버린다. DAG 파일을 배포할 수 있는 사용자(신뢰도가 낮을 수도 있는 "DAG 작성자")가
스케줄러/API 서버 프로세스에서 임의 코드를 실행할 수 있게 되는, Airflow의 멀티테넌시
모델을 정면으로 위협하는 취약점이다.
Airflow의 DAG은 본질적으로 파이썬 스크립트다(dag.py 안에 with DAG(...) as dag: 로
태스크를 정의). 만약 스케줄러가 몇 초에 한 번씩 이 파일을 그대로 import해서 실행한다면,
DAG 파일 안에 있는 임의의 파이썬 코드(모듈 최상단의 부작용, 악의적인 import 등)가 매번
스케줄러 프로세스 권한으로 실행되는 셈이 된다. Airflow는 이를 피하기 위해 DAG 파일을
한 번 파싱하는 전용 프로세스(DAG 프로세서) 에서만 import하고, 그 결과로 나온
"이 DAG에는 어떤 태스크가 있고 각 태스크의 설정값은 무엇인가"라는 정보를 SerializedDagModel
이라는 형태로 데이터베이스에 저장한다. 스케줄러와 API 서버는 이후 DAG 파일을 다시 열어보는
대신, 이 직렬화된 데이터만 읽어서(BaseSerialization.deserialize()) 스케줄링
판단을 내린다. 즉 "직렬화 계층"은 DAG 작성자의 코드와, 코드를 실제로 실행하지 않고도
그 구조만 알아야 하는 특권 프로세스(스케줄러/API 서버) 사이를 갈라놓는 격벽 역할을 한다.
Airflow 2.2부터 도입된 "지연 가능한 오퍼레이터(deferrable operator)"는, 예를 들어
"외부 API가 응답할 때까지 기다린다"처럼 그저 대기만 하는 태스크가 워커 슬롯 하나를
계속 붙잡고 있지 않도록 설계됐다. 태스크가 대기 상태로 들어가면(self.defer(trigger=...)),
워커는 그 태스크를 내려놓고 다른 일을 할 수 있게 되며, "무엇을 기다리는가"에 대한 정보는
Trigger 라는 가벼운 레코드(트리거 클래스 경로 문자열 + 그 생성자에 넘길 kwargs)로 DB에
저장된다. 이 대기는 별도의 경량 프로세스인 트리거러(Triggerer) 가 비동기로 수백~수천
개를 한꺼번에 감시하다가, 조건이 충족되면 원래 태스크를 다시 워커에서 재개시킨다.
StartTriggerArgs는 "이 오퍼레이터가 맨 처음부터 곧바로 지연 상태로 시작해야 한다"는
경우(start_from_trigger = True)를 위해, DAG을 직렬화할 때 트리거 클래스 경로
(trigger_cls)와 그 kwargs(trigger_kwargs), 재개 시 호출할 메서드(next_method)와
그 kwargs(next_kwargs)를 함께 담아 두는 자료구조다.
Airflow의 직렬화 포맷은 JSON이 기본적으로 표현하지 못하는 파이썬 타입(datetime, set,
tuple, 커스텀 예외 객체 등)을 다루기 위해, 각 값을 {"__type": "<태그>", "__var": <실제 데이터>}
형태로 감싼다. BaseSerialization.deserialize()는 이 __type 태그를 보고 그에 맞는
복원 로직을 분기 실행하는 하나의 거대한 if/elif 디스패처다 — 예를 들어 태그가
"datetime"이면 var를 timestamp로 해석해 datetime 객체를 만들고, 태그가
"timedelta"면 초 단위 숫자로 해석한다. 문제는 이 디스패처 안에 문자열을 그대로
import_string()에 넘겨 임의의 모듈 경로를 불러오고, 그 결과로 얻은 클래스를 즉시
인스턴스화하는 분기가 여러 개 존재했다는 점이다 — 트리거 객체(DAT.BASE_TRIGGER)와
예외 객체(DAT.AIRFLOW_EXC_SER/DAT.BASE_EXC_SER)가 그것이다. import_string() 자체는
파이썬에서 흔히 쓰는 "문자열로 지정된 모듈 경로를 동적으로 불러오는" 유틸리티일 뿐이며,
어떤 경로든 그 경로가 실제로 import 가능하기만 하면(설치된 패키지, 표준 라이브러리,
Airflow 자체의 아무 클래스든) 제한 없이 불러온다.
수정 전 serialized_objects.py의 _decode_start_trigger_args()는 다음과 같았다(수정
직전 커밋 기준 실제 발췌).
# airflow-core/src/airflow/serialization/serialized_objects.py (수정 전)
def _decode_start_trigger_args(var: dict[str, Any]) -> StartTriggerArgs:
"""Decode a 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,
)이 함수는 스케줄러/API 서버가 직렬화된 DAG을 메모리로 복원할 때마다 호출된다 —
즉 사용자가 아무 API도 특별히 호출하지 않고 DAG 목록을 조회하기만 해도 실행되는
경로다. trigger_kwargs/next_kwargs는 DAG 작성자가 오퍼레이터 정의에서 자유롭게
채워 넣는 값인데, 이 함수는 그 값을 그대로 BaseSerialization.deserialize()에 넘긴다.
그리고 BaseSerialization.deserialize() 안에는 다음과 같은 분기가 있었다(같은 파일,
수정 전).
elif type_ == DAT.BASE_TRIGGER:
tr_cls_name, kwargs = cls.deserialize(var)
tr_cls = import_string(tr_cls_name)
return tr_cls(**kwargs)tr_cls_name과 kwargs는 모두 직렬화된 데이터 안에서 그대로 꺼낸 값이다 — 즉 DAG
작성자가 trigger_kwargs 안에 {"__type": "base_trigger", "__var": ["<원하는 임의의 import 가능한 클래스 경로>", {"<생성자 kwargs>": ...}]} 형태의 값을 심어 두면,
import_string(tr_cls_name)은 이를 검증 없이 그대로 import하고, tr_cls(**kwargs)로
즉시 인스턴스화한다. import_string에 경로 제한(allowlist)이 없으므로 Airflow
자체나 설치된 어떤 패키지의 클래스든 지정할 수 있고, 그 __init__(또는 생성 과정에서
호출되는 다른 메서드)에 파일 시스템 접근·프로세스 실행 등 부작용이 있는 클래스를
고르면 임의 코드 실행으로 이어진다. AIRFLOW_EXC_SER/BASE_EXC_SER(예외 역직렬화)
분기도 같은 패턴(import_string(exc_cls_name) 후 exc_cls(*args, **kwargs))을 갖고
있어 동일한 성격의 위험을 안고 있었다.
Airflow의 위협 모델에서 DAG 작성자는 워커·트리거러에서는 임의 코드를 실행할 수 있는
것으로 이미 간주된다(애초에 DAG 자체가 파이썬 코드이므로). 하지만 스케줄러와 API
서버는 DAG 작성자보다 더 높은 신뢰 등급으로 취급된다 — 이 두 프로세스는 클러스터
전체의 모든 DAG/태스크 상태를 다루고, 종종 더 넓은 네트워크/자격 증명 접근 권한을
갖기 때문이다. 이 취약점은 "낮은 신뢰의 DAG 작성자가 심은 값 → 직렬화 계층을 거쳐 →
더 높은 신뢰의 스케줄러/API 서버 프로세스에서 실행"이라는 경로를 열어, 정확히 이
경계를 뚫는다. 특히 문제를 더 심각하게 만드는 것은 이 역직렬화가 DAG을 그저 조회하기만
해도 자동으로 일어난다는 점이다 — 별도의 위험한 API를 호출할 필요 없이, 악의적인
trigger_kwargs를 담은 DAG 파일 하나가 DAG 프로세서에 의해 파싱·직렬화되고 나면,
그 이후 스케줄러/API 서버가 그 DAG을 스케줄링 대상으로 다루는 매 순간 공격이 실행된다.
DAG 파일 (낮은 신뢰 — DAG 작성자가 자유롭게 작성)
+----------------------------------------------------+
| class EvilOp(BaseOperator): |
| start_trigger_args = StartTriggerArgs( |
| trigger_cls="...", |
| trigger_kwargs={ |
| "__type": "base_trigger", |
| "__var": ["os.system 등 부작용 있는 경로", |
| {"...": "..."}] |
| }, |
| ...) |
+----------------------------------------------------+
│ DAG 프로세서가 파싱 후 직렬화 (BaseSerialization.serialize)
▼
+----------------------------------------------------+
| SerializedDagModel (DB에 저장된 JSON) |
+----------------------------------------------------+
│ 스케줄러/API 서버가 DAG 조회 시마다 역직렬화
▼
_decode_start_trigger_args()
│
▼
BaseSerialization.deserialize(trigger_kwargs)
│ __type == "base_trigger" 분기
▼
tr_cls = import_string(tr_cls_name) <- 검증 없이 임의 경로 import
return tr_cls(**kwargs) <- 즉시 인스턴스화 (부작용 실행)
│
▼
+----------------------------------------------------+
| 스케줄러 / API 서버 프로세스 | <- 신뢰 경계를 넘어옴
| (DAG 작성자보다 높은 권한/신뢰 등급) |
+----------------------------------------------------+
수정은 두 단계로 이뤄졌다 — 먼저 "당장 위험한 호출 지점을 없애는" 최소 수정(#66002)이 있었고, 뒤이어 "애초에 그 위험한 분기 자체를 코드베이스에서 제거하는" 근본적인 수정 (#68528)이 있었다.
# airflow-core/src/airflow/serialization/serialized_objects.py (수정 후)
def _decode_start_trigger_args(var: dict[str, Any]) -> StartTriggerArgs:
"""Decode a StartTriggerArgs."""
return StartTriggerArgs(
trigger_cls=var["trigger_cls"],
trigger_kwargs=var["trigger_kwargs"], # BaseSerialization.deserialize() 호출 제거
next_method=var["next_method"],
next_kwargs=var["next_kwargs"], # 마찬가지
timeout=datetime.timedelta(seconds=var["timeout"]) if var["timeout"] else None,
)이제 스케줄러/API 서버는 trigger_kwargs/next_kwargs를 원본 JSON 형태 그대로
보관할 뿐, import_string을 호출하는 어떤 역직렬화도 거치지 않는다. 실제로 이 값을
파이썬 객체로 복원해 사용해야 하는 쪽은 트리거러뿐인데, 트리거러는 애초에 DAG
직렬화 계층을 거치지 않고 Trigger DB 레코드(classpath 문자열 + 암호화된 kwargs
blob)를 직접 읽어 트리거 인스턴스를 만들므로 이 변경으로 정상 동작에는 영향이 없다.
1단계는 "이 특정 호출 지점"의 위험을 없앴을 뿐, BaseSerialization.deserialize()
자체에는 여전히 DAT.BASE_TRIGGER 분기(import_string + 즉시 인스턴스화)가 남아있어
다른 경로로 같은 패턴이 재발할 여지가 있었다. #68528은 이 분기가 실제로는 이미 폐기된
기능(AIP-44 Internal API — 컴포넌트 간 RPC로 런타임 객체를 넘기던 옛 아키텍처)의
잔재였고, 살아있는 코드 경로 어디서도 더 이상 쓰이지 않는다는 것을 확인한 뒤 인코딩/
디코딩 분기 자체를 완전히 제거했다.
# airflow-core/src/airflow/serialization/serialized_objects.py (PR #68528 이후)
# deserialize() 안에서 아래 분기 자체가 삭제됨:
# elif type_ == DAT.BASE_TRIGGER:
# tr_cls_name, kwargs = cls.deserialize(var)
# tr_cls = import_string(tr_cls_name)
# return tr_cls(**kwargs)DAT.BASE_TRIGGER enum 값, serialize() 쪽의 인코딩 분기, 관련 테스트까지 함께
제거되어 이 "가젯" 자체가 더 이상 코드베이스에 존재하지 않는다. 참고로 예외 역직렬화
(AIRFLOW_EXC_SER/BASE_EXC_SER)의 import_string 분기는 XCom·next_kwargs 등을
통해 예외 객체가 실제로 오갈 필요가 있어 완전히 제거하지는 못했고, 대신 별도 PR
(#68511, _safe_import_for_deserialize)이 이 경로에 허용 목록(allowlist) 기반의
안전한 import 검증을 추가해 위험을 좁혔다. 벤더 권고문이 함께 언급하는 심층 방어 옵션인
[core] allowed_deserialization_classes 설정도 같은 맥락으로, DAG 작성자 신뢰도가
낮은 환경에서는 이 값을 좁은 허용 목록으로 제한할 것을 권고한다.
DAG 파일 (낮은 신뢰)
│
▼
SerializedDagModel (DB)
│ 스케줄러/API 서버가 조회
▼
_decode_start_trigger_args()
│
▼
trigger_kwargs = var["trigger_kwargs"] <- 원본 JSON 그대로 보관, deserialize() 호출 없음
(겹치는 지점: 여기서 import_string 경로 자체가 차단됨)
│
▼
+----------------------------------------------------+
| 스케줄러 / API 서버 프로세스 |
| - import_string() 호출 없음 |
| - DAT.BASE_TRIGGER 분기 자체가 deserialize()에서 삭제 |
+----------------------------------------------------+
(트리거 인스턴스가 실제로 필요한 트리거러는 별도 경로로 동작)
Trigger DB 레코드 (classpath 문자열 + 암호화된 kwargs)
│ 트리거러가 직접 읽음 (DAG 직렬화 계층을 거치지 않음)
▼
트리거러 프로세스에서만 import_string(classpath) 실행
(트리거러는 이미 이런 트리거 인스턴스화를 담당하도록 설계된 컴포넌트)
- oss-security 메일링 리스트 — "CVE-2026-33264: Apache Airflow: DAG author RCE on webserver via unrestricted import_string" (Rahul Vats, Apache Airflow Security Team, 2026-07-07) — 공식 취약점 설명, 영향 버전(3.3.0 미만), 발견자·수정자 크레딧, 참조 PR 링크. openwall.com 원본은 302 리다이렉트로 자동화 접근이 어려워 marc.info 미러로 원문을 확인함. (검증됨, 1차 자료)
- NVD CVE-2026-33264 — CVSS v3.1 9.8(Critical), 공개일 2026-07-07. (검증됨)
- 수정 PR #66002 — "Do not deserialize trigger_kwargs when loading serialized DAGs", apache/airflow —
_decode_start_trigger_args()의 실제 diff(serialized_objects.py,triggers/base.py등), 병합일 2026-05-07. (검증됨, 1차 자료) - 수정 PR #68528 — "Remove dead AIP-44 trigger-over-BaseSerialization path (DAT.BASE_TRIGGER)", apache/airflow —
BaseSerialization.deserialize()의DAT.BASE_TRIGGER분기 완전 삭제 diff, 병합일 2026-06-17. PR 설명에 관련 PR(#67926, #68511)과의 관계도 명시됨. (검증됨, 1차 자료) - apache/airflow 저장소 —
serialized_objects.py수정 전 버전(커밋ff15983262376fb375a67e23ecbc5c2a32786fa0) —DAT.BASE_TRIGGER/AIRFLOW_EXC_SER분기의 실제 취약 코드(import_string+ 즉시 인스턴스화) 원문 확인. (검증됨, 1차 자료)
import_string(tr_cls_name)으로 불러온 임의 클래스의__init__(또는 그 과정에서 호출되는 다른 메서드)이 구체적으로 어떤 부작용을 일으켜야 실질적인 RCE로 이어지는지 — 즉 실제 공격에 쓰일 수 있는 "가젯 클래스"의 구체적인 예시는 정적 분석 범위(패치 diff와 공식 advisory)에서는 확인하지 못했다. 이 리포트는 근본 원인(검증되지 않은import_string+ 인스턴스화)과 패치 내용에 집중했다.- PR #68528이 언급하는 관련 PR #67926(
DAT.BASE_TRIGGER디코드를 게이트하던 선행 조치)과 #68511(_safe_import_for_deserialize, 예외 역직렬화 경로의 허용 목록 게이트)의 세부 diff는 이번 조사에서 직접 열어보지 않았고, #68528의 PR 설명文에 요약된 내용만 인용했다.