Skip to content

Latest commit

 

History

History
276 lines (236 loc) · 17.3 KB

File metadata and controls

276 lines (236 loc) · 17.3 KB

Ray read_webdataset 기본 디코더의 무조건 pickle/torch.load 역직렬화로 인한 RCE (CVE-2026-57516)

메타데이터

항목 내용
CVE ID CVE-2026-57516
영향 소프트웨어 Ray (ray.data.read_webdataset, ray-project/ray)
영향 버전 2.56.0 미만 전체
CVSS v3.1 8.8 (High) — AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H (GHSA-hhrp-gw25-jr43)
CWE CWE-94 (Code Injection), CWE-502(Deserialization of Untrusted Data)
공개일 2026-07-24 (GitHub Security Advisory), NVD 최초 게시 2026-07-01
수정 버전 2.56.0
수정 커밋 41443a18f9e6403a072de69098a279c23e2d943c (PR #63469)
GHSA GHSA-hhrp-gw25-jr43

개요

Ray는 분산 컴퓨팅/데이터 처리 프레임워크이며, ray.data는 여러 워커 프로세스에 데이터를 분산해 읽고 처리하는 라이브러리다. 이 중 ray.data.read_webdataset()은 WebDataset 포맷(샘플들을 하나로 묶은 TAR 아카이브)을 읽는 API로, 확장자에 따라 자동으로 값을 디코딩하는 기본 디코더(decoder=True)를 기본값으로 사용한다. CVE-2026-57516은 이 기본 디코더가 .pkl/.pickle 파일은 pickle.loads()로, .pt/.pth 파일은 torch.load(weights_only=False)아무 검증 없이 역직렬화한다는 결함이다. 두 함수 모두 공격자가 준비한 바이트열로부터 임의 코드를 실행시킬 수 있는 전형적인 파이썬 역직렬화 가젯이므로, 신뢰할 수 없는 출처(공유 S3 버킷, HTTP URL, HuggingFace Hub 미러 등)의 TAR 파일을 read_webdataset()으로 미리보기만 해도 그 파일을 처리한 Ray 워커 프로세스에서 공격자 코드가 실행된다. 2024년에 이미 같은 계열의 Parquet 경로 취약점(GHSA-mw35-8rx3-xf9r)이 수정된 바 있지만, 이번 결함은 다른 코드 경로(WebDataset 리더)에 남아 있던 동일한 유형의 문제다.

사전 지식

Ray의 driver/worker 구조와 ray.data가 실행되는 위치

Ray 애플리케이션은 사용자가 직접 실행하는 driver 프로세스와, 클러스터 전체에 흩어져 실제 작업을 수행하는 worker 프로세스들로 이루어진다. ray.data로 데이터셋을 읽으면, Ray는 대상 파일들을 여러 개의 블록으로 나누고 각 블록을 읽는 작업(read task)을 클러스터의 워커들에게 분산시켜 스케줄링한다. 즉 사용자가 로컬 스크립트에서 ds.take_all()처럼 데이터를 미리보기만 해도, 실제 파일을 열고 바이트를 해석하는 코드는 드라이버가 아니라 어딘가의 워커 프로세스 안에서 실행된다. 이 구조 때문에, 하나의 악성 TAR 파일이 여러 블록으로 쪼개져 여러 워커에 분산되면 그 워커 각각에서 공격자 코드가 실행될 수 있다 — 단일 프로세스의 침해가 아니라 클러스터 전체로 번질 수 있는 문제가 된다.

WebDataset 포맷과 "확장자가 곧 타입"이라는 관례

WebDataset은 여러 파일을 순서대로 묶은 일반 TAR 아카이브다. 000000.jpg, 000000.json, 000000.pkl처럼 같은 접두사(000000)를 공유하는 파일들이 모여 하나의 "샘플"을 이루고, 각 파일의 확장자가 그 값을 어떻게 해석해야 하는지를 결정한다. ray.data.read_webdataset()은 이 관례를 그대로 구현해서, TAR 안의 파일을 순회하며 같은 접두사끼리 묶은 뒤(_group_by_keys), 각 값의 확장자를 보고 디코더 함수(_default_decoder)에 위임한다. 이 설계 자체는 자연스럽지만, 확장자 목록에 .pkl/.pt처럼 임의의 파이썬 객체를 그대로 복원하는 포맷이 포함되어 있다는 점이 다음 절의 근본 원인으로 이어진다.

pickletorch.load가 왜 "역직렬화 = 코드 실행"인가

파이썬의 pickle 포맷은 단순한 데이터 직렬화가 아니라, 역직렬화 과정에서 임의의 객체 생성자와 __reduce__ 메서드를 호출할 수 있는 작은 가상 머신에 가깝다. 공격자는 __reduce__os.system(...)이나 subprocess.Popen(...) 같은 호출을 반환하도록 만든 객체를 pickle.dumps()로 직렬화해 두면, 그 바이트열을 pickle.loads()로 읽는 쪽에서 해당 호출이 그대로 실행된다. torch.load()도 내부적으로 pickle을 사용해 텐서 외의 임의 파이썬 객체를 복원하기 때문에 같은 위험을 갖는다. 이런 이유로 PyTorch는 2.6부터 torch.load()의 기본값을 weights_only=True(안전하게 텐서/기본 타입만 로드)로 바꾸었다 — 즉 상류(upstream) 프로젝트조차 "기본값은 안전해야 한다"는 방향으로 이미 이동한 상태였다.

취약점 분석

패치 전 코드: 신뢰 경계 없이 바로 역직렬화

python/ray/data/_internal/datasource/webdataset_datasource.py_default_decoder()는 확장자별로 분기하며, .pt/.pth.pkl/.pickle 분기에서 검증 없이 바로 역직렬화를 수행했다.

def _default_decoder(sample: Dict[str, Any], format: Optional[Union[bool, str]] = True):
    sample = dict(sample)
    for key, value in sample.items():
        extension = key.split(".")[-1]
        ...
        elif extension in ["pt", "pth"]:
            import torch

            # PyTorch 2.6 changed torch.load default weights_only=True, which
            # breaks loading general Python objects previously serialized for
            # WebDataset .pt payloads.
            sample[key] = torch.load(io.BytesIO(value), weights_only=False)
        elif extension in ["pickle", "pkl"]:
            import pickle

            sample[key] = pickle.loads(value)
    return sample

주목할 부분은 .pt/.pth 분기의 주석이다. 이 주석은 "PyTorch 2.6이 기본값을 안전한 쪽으로 바꿔서 기존 동작이 깨졌다"는 사실을 설명하며, 그 호환성을 지키기 위해 의도적으로 weights_only=False를 명시했다. 즉 상류가 닫은 안전장치를 Ray 쪽에서 다시 열어 둔 셈이다.

이 디코더가 실제로 호출되는 경로는 WebDatasetDatasource._read_stream()이다.

samples = _group_by_keys(files, meta=dict(__url__=path), suffixes=self.suffixes)
for sample in samples:
    if self.decoder is not None:
        sample = _apply_list(self.decoder, sample, default=_default_decoder)

그리고 공개 API인 read_webdataset()(python/ray/data/read_api.py)은 decoder 파라미터의 기본값을 True로 두고 있었다. self.decoderTrue(≠ None)이므로 위 if self.decoder is not None: 조건은 항상 참이 되고, 결과적으로 아무 옵션도 지정하지 않고 read_webdataset(paths=...)를 호출하기만 해도 _default_decoder가 모든 샘플에 대해 실행된다. 사용자가 무언가를 "켜야" 위험해지는 것이 아니라, 기본 동작 자체가 위험한 구조였다.

┌──────────────────────────────────────────────────────────────────┐
│ 패치 전: 신뢰 경계가 없는 WebDataset 읽기 경로                     │
├──────────────────────────────────────────────────────────────────┤
│ 공격자                                                             │
│   TAR 안에 000000.pkl = pickle.dumps(EvilObject()) 를 넣어 둠      │
│         │ (S3 공유 버킷 / HTTP URL / HF Hub 미러 등으로 배포)       │
│         ▼                                                          │
│ 사용자(피해자)                                                     │
│   ds = ray.data.read_webdataset(paths=["<attacker-controlled>"])  │
│   ds.take_all()                                                    │
│         │                                                          │
│         ▼  (Ray 클러스터의 어느 워커 프로세스에서 실행)             │
│ WebDatasetDatasource._read_stream()                                │
│   decoder=True (기본값, None 아님) → _apply_list(...) 호출          │
│         │                                                          │
│         ▼                                                          │
│ _default_decoder(sample)                                           │
│   extension == "pkl" → pickle.loads(value)  ← 검증 없음            │
│         │                                                          │
│         ▼                                                          │
│ EvilObject.__reduce__() 가 반환한 (os.system, (cmd,)) 실행          │
│   → 워커 프로세스 권한으로 임의 명령 실행                          │
└──────────────────────────────────────────────────────────────────┘

왜 "스키마 미리보기" 같은 가벼운 호출도 위험한가

GHSA 권고문은 이 역직렬화가 "행 데이터를 실제로 소비하기도 전, 스키마 샘플링 시점에" 발생한다고 명시한다. Ray Data는 데이터셋의 컬럼/타입을 파악하기 위해 일부 샘플을 미리 읽어 스키마를 추론하는데, 이 추론 과정 자체가 _default_decoder를 거치기 때문에, 사용자가 전체 데이터를 순회하지 않고 데이터셋 구조만 확인하려는 의도로도 이미 공격자 코드가 실행된다. 이는 "위험한 기능은 실제로 쓰일 때만 위험하다"는 직관을 깨는 지점으로, 개발자가 방어적으로 "일단 구조만 보자"고 판단한 순간에도 노출된다는 뜻이다.

보안 영향

AV:N/AC:L/PR:N/UI:R (CVSS v3.1 8.8)이 의미하듯, 공격자는 인증 없이 네트워크로 도달 가능한 위치(예: 신뢰하지 않는 소스의 URL)에 파일을 두는 것만으로 공격을 준비할 수 있고, 필요한 사용자 개입은 피해자가 그 경로를 read_webdataset()에 넘기는 것뿐이다. 영향 범위는 기밀성·무결성·가용성 모두 High로, 해당 Ray 워커 프로세스 권한으로 임의 코드 실행이 가능하다는 뜻이다. Ray는 학습 데이터 파이프라인에서 널리 쓰이므로, 실제 공격 경로는 공유 데이터셋 버킷, 모델 리더보드, 커뮤니티가 배포한 WebDataset shard처럼 "출처를 완전히 통제하기 어려운 학습 데이터"를 통해 열릴 수 있다.

수정 방법

수정 커밋(PR #63469)은 두 안전하지 않은 확장자 분기를 환경 변수로 명시적으로 켜야만 동작하는 opt-in으로 바꾸고, 기본 상태에서는 예외를 던지도록 했다.

+ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR = (
+    "RAY_DATA_WEBDATASET_ALLOW_UNSAFE_DESERIALIZATION"
+)
+
-def _default_decoder(sample: Dict[str, Any], format: Optional[Union[bool, str]] = True):
+def _default_decoder(
+    sample: Dict[str, Any],
+    format: Optional[Union[bool, str]] = True,
+    allow_unsafe: bool = False,
+):
     sample = dict(sample)
     for key, value in sample.items():
         ...
         elif extension in ["pt", "pth"]:
+            if not allow_unsafe:
+                raise ValueError(
+                    f"Refusing to load .{extension} member {key!r} from "
+                    f"WebDataset with weights_only=False (arbitrary code "
+                    f"execution risk). Provide a custom decoder or set "
+                    f"{ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR}=1 "
+                    f"for trusted sources."
+                )
             import torch
             sample[key] = torch.load(io.BytesIO(value), weights_only=False)
         elif extension in ["pickle", "pkl"]:
+            if not allow_unsafe:
+                raise ValueError(
+                    f"Refusing to unpickle WebDataset member {key!r} "
+                    f"(arbitrary code execution risk). Provide a custom "
+                    f"decoder or set "
+                    f"{ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR}=1 "
+                    f"for trusted sources."
+                )
             import pickle
             sample[key] = pickle.loads(value)
     return sample

그리고 WebDatasetDatasource.__init__()에서 이 환경 변수를 한 번 읽어 두었다가, _read_stream()_default_decoder를 호출할 때 functools.partialallow_unsafe 값을 넘긴다.

+        self._allow_unsafe_deserialization = env_bool(
+            ALLOW_UNSAFE_DESERIALIZATION_ENV_VAR, False
+        )
...
+        default_decoder = partial(
+            _default_decoder, allow_unsafe=self._allow_unsafe_deserialization
+        )
         for sample in samples:
             if self.decoder is not None:
-                sample = _apply_list(self.decoder, sample, default=_default_decoder)
+                sample = _apply_list(self.decoder, sample, default=default_decoder)

이 패치는 API 시그니처(decoder=True 기본값)나 확장자 관례 자체는 건드리지 않는다. 대신 "기본값은 안전해야 한다"는 원칙을 지키기 위해, 위험한 두 분기만 명시적 opt-in(RAY_DATA_WEBDATASET_ALLOW_UNSAFE_DESERIALIZATION=1) 뒤에 가두고, 그 필요성이 없는 사용자에게는 명확한 에러 메시지로 왜 실패했는지와 대안 (직접 안전한 decoder 콜백을 제공하는 방법)을 안내한다.

┌────────────────────────────────────────────────────────────────┐
│ 패치 후: 기본값은 거부, 신뢰하는 경우에만 명시적으로 허용         │
├────────────────────────────────────────────────────────────────┤
│ ds = ray.data.read_webdataset(paths=[...])   # 환경변수 미설정   │
│         │                                                        │
│         ▼                                                        │
│ _default_decoder(..., allow_unsafe=False)                        │
│   extension == "pkl" → allow_unsafe? NO                          │
│         └─ raise ValueError("Refusing to unpickle ...")          │
│                                                                    │
│ (신뢰하는 내부 데이터셋에 한해)                                   │
│ RAY_DATA_WEBDATASET_ALLOW_UNSAFE_DESERIALIZATION=1 설정 시에만    │
│         └─ 기존과 동일하게 pickle.loads / torch.load 수행         │
└────────────────────────────────────────────────────────────────┘

Ray 2.56.0 미만 버전을 사용 중이라면 2.56.0 이상으로 업그레이드해야 하며, 그 전까지 신뢰할 수 없는 출처의 WebDataset TAR를 read_webdataset()으로 열어야 한다면 decoder=None과 직접 작성한 안전한 디코더(예: .pkl/.pt 확장자를 건너뛰거나 별도로 검증하는 콜백)를 명시적으로 지정해 기본 디코더 경로 자체를 피하는 것이 임시 완화책이 된다.

더 살펴볼 점

  • Parquet 경로(GHSA-mw35-8rx3-xf9r)와 WebDataset 경로가 같은 유형의 문제를 독립적으로 반복한 이유는 무엇이며, Ray Data 안에 유사한 확장자 기반 자동 역직렬화 지점이 더 남아있지는 않은지 점검해볼 만하다.
  • torch.load(weights_only=True)가 실제로 어떤 타입까지 안전하게 허용하는지, 그리고 이 allowlist 방식 자체가 우회 가능한 지점은 없는지 비교해보면 좋다.
  • 분산 클러스터에서 "어떤 워커가 이 블록을 처리할지"를 공격자가 예측하거나 조작할 수 있다면 피해 범위가 어떻게 달라질지도 생각해볼 문제다.

참고 자료

참고 외 별도 확인 링크

본문 참고 자료로 충분했다.