신뢰되지 않은 데이터 역직렬화

Deserialization of Untrusted Data

설명

역직렬화는 저장하거나 전송한 표현을 애플리케이션 객체로 복원하는 과정입니다. Python의 역직렬화 방식은 다음과 같이 서로 다른 보안 특성을 가집니다.

  • pickle.load, pickle.loads와 Unpickler.load()는 객체를 복원하면서 전역 함수나 클래스를 찾고 호출할 수 있습니다. Python은 pickle을 안전하지 않은 모듈로 명시하며 신뢰할 수 있는 데이터만 언피클링하도록 안내합니다. 악성 pickle은 언피클링 도중 임의 코드를 실행할 수 있습니다.
  • PyYAML의 yaml.unsafe_load와 Loader, CLoader, UnsafeLoader, CUnsafeLoader는 Python 객체를 구성하는 태그를 허용합니다. 신뢰되지 않은 YAML에는 이러한 로더를 사용하면 안 됩니다.
  • marshal은 주로 Python의 .pyc 지원을 위한 내부 형식입니다. Python은 악의적으로 구성된 데이터에 안전하지 않다고 명시합니다. allow_code=True이면 코드 객체를 반환할 수 있지만, 언마샬링 자체가 그 코드 객체를 실행하는 것은 아닙니다. 손상된 입력, 버전이 맞지 않는 코드 객체 및 역직렬화 결과의 후속 사용은 예외, 정의되지 않은 동작 또는 추가 보안 문제를 일으킬 수 있습니다.

따라서 pickle 또는 안전하지 않은 PyYAML 로더의 코드 실행 위험과 marshal의 내부 형식·무결성·가용성 위험을 같은 동작으로 설명해서는 안 됩니다.

잠재적 영향

  • 임의 코드 실행: 악성 pickle 또는 안전하지 않은 PyYAML Python 태그가 애플리케이션 권한으로 함수나 명령을 실행할 수 있습니다.
  • 데이터 및 상태 변조: 예상하지 않은 객체나 값이 생성되어 애플리케이션 상태와 저장 데이터를 변경할 수 있습니다.
  • 정보 노출: 실행된 코드나 후속 처리 로직이 파일, 비밀 값 또는 환경 정보에 접근할 수 있습니다.
  • 서비스 거부: 악의적이거나 과도하게 큰 입력이 CPU, 메모리 또는 재귀 한도를 소모하거나 반복적인 예외를 유발할 수 있습니다.
  • 호환성 및 안정성 문제: marshal 코드 객체는 Python 버전 간 호환되지 않으며, 잘못된 버전에서 역직렬화하면 정의되지 않은 동작이 발생할 수 있습니다.

역직렬화 형식 선택

신뢰할 수 없는 입력에는 JSON이나 yaml.safe_load처럼 데이터만 표현하는 형식을 우선 사용하세요. Unpickler를 변수에 저장해 호출하거나 사용자 정의 로더를 사용해도 입력의 신뢰성을 검증할 필요는 동일합니다.

해결 방법

  1. 외부 입력 경로에서 pickle과 marshal을 제거하고 JSON처럼 데이터만 표현하는 형식으로 교체합니다.
  2. 입력 크기를 제한하고 파싱 결과의 객체 종류, 필수 필드, 값의 타입·길이·범위를 검증합니다. 스키마 검증은 데이터 전용 파서 뒤에 수행해야 하며, 안전하지 않은 역직렬화 뒤의 검증은 코드 실행을 막지 못합니다.
  3. 신뢰되지 않은 YAML에는 yaml.safe_load 또는 yaml.load(..., Loader=yaml.SafeLoader)를 사용합니다. 사용 가능한 환경에서는 CSafeLoader도 같은 제한된 생성자 집합을 사용합니다.
  4. 레거시 객체 형식을 즉시 제거할 수 없다면 신뢰 경계를 바꿔 인증된 신뢰 가능한 생성 주체만 데이터를 만들게 하고, 역직렬화 전에 정확한 바이트의 진위성과 무결성을 검증합니다. HMAC은 비밀 키를 모르는 공격자의 변조를 막을 수 있지만 악의적인 권한 보유 생성 주체의 페이로드를 안전하게 만들지는 않습니다.
  5. marshal.loads(..., allow_code=False), HTTP Content-Type, 인증·인가만 수행하는 방식, 역직렬화 이후의 스키마 검사 또는 허용 범위가 넓은 사용자 정의 Unpickler를 단독 해결책으로 사용하지 않습니다.
  6. 크기, 시간 및 메모리 제한은 가용성 방어로 추가하되, 신뢰 경계에서 안전하지 않은 역직렬화 함수를 제거하는 조치를 대신하게 해서는 안 됩니다.

예시

변경 전

python
import pickle

from flask import Flask, request

app = Flask(__name__)


@app.post("/profile/import")
def import_profile():
    raw = request.get_data(cache=False)
    profile = pickle.loads(raw)  # 악성 pickle은 이 호출 중 코드를 실행할 수 있음
    return {"name": str(profile.get("name", ""))}

요청 본문을 검사하기 전에 pickle.loads가 실행되므로, 반환된 객체를 검증해도 언피클링 중 발생한 코드 실행을 되돌릴 수 없습니다.

변경 후

외부 API가 JSON만 받도록 계약을 변경하고 파싱 후 스키마를 검증합니다.

python
from flask import Flask, abort, request

app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 64 * 1024


@app.post("/profile/import")
def import_profile():
    profile = request.get_json()
    if not isinstance(profile, dict):
        abort(400, "profile must be an object")

    name = profile.get("name")
    if not isinstance(name, str) or not 1 <= len(name) <= 100:
        abort(400, "invalid name")

    return {"name": name}

YAML이 반드시 필요하면 안전 로더로 기본 값만 만든 뒤 동일하게 스키마를 검증합니다.

python
import yaml
from flask import abort, request


def read_yaml_profile():
    profile = yaml.safe_load(request.get_data(cache=False))
    if not isinstance(profile, dict):
        abort(400, "profile must be a mapping")
    return profile

yaml.safe_load도 입력 크기와 애플리케이션 스키마를 대신 검증하지는 않습니다. 크기 제한과 파싱 후 검증을 함께 적용해야 합니다.

참조