● CWE-22 · CVE-2007-4559

Python tarfile.extractall 경로 조작(CVE-2007-4559): 발생 원리와 해결 방법

압축 해제 필터가 생기기 전의 tarfile.extractall()은 각 멤버를 아카이브에 저장된 경로에 그대로 썼기 때문에, ..이 들어간 이름, 절대 경로, 링크로 대상 폴더 밖에 파일을 둘 수 있었습니다. PEP 706의 filter="data" 인자가 이를 어떻게 막는지, 대상 경로를 사용자 입력으로 정하면 안 되는 이유, 추가해야 할 크기 제한을 Python 예제로 설명합니다.

알기 쉬운 설명 (ELI5)

이삿짐 업체가 새집에서 상자를 풀고, 각 물건을 라벨에 적힌 곳에 둡니다. '주방, 그다음 현관 밖으로, 그다음 옆집 침실'이라고 적힌 라벨도 글자 그대로 따릅니다. 인부들은 라벨만 읽을 뿐 그 주소가 여전히 당신 집 안인지 확인하지 않기 때문입니다. 아카이브도 똑같이 파일마다 자기 목적지 경로를 가지고 있습니다. 해결책은 모든 라벨을 먼저 읽고, 집 밖으로 나가는 것과 다른 곳을 가리키는 표지판처럼 평범한 상자가 아닌 것을 모두 거절하는 현장 감독을 두는 것입니다.

핵심 개념 및 용어

CVE-2007-4559
Python의 tarfile 모듈은 멤버 이름이 압축 해제 디렉터리 밖으로 나가는 것을 막지 않았습니다. 이 문제는 2007년부터 알려져 있었고, 2022년 Trellix는 약 35만 개의 오픈소스 프로젝트가 신뢰할 수 없는 아카이브에 extractall()을 호출한다고 추정했습니다.
.. 또는 절대 경로가 들어간 멤버 이름
../outside.txt나 /etc/cron.d/job이라는 이름의 tar 항목은 대상 경로와 합치면 그 밖을 가리킵니다. 필터가 없으면 extractall()은 프로세스 권한으로 그 경로에 씁니다.
심볼릭 링크와 하드 링크 멤버
아카이브는 먼저 대상 안에 바깥을 가리키는 링크를 만든 뒤, 그 링크를 통해 일반 파일을 쓸 수 있습니다. 이름만 검사해서는 이를 잡을 수 없으므로 필터는 링크 대상도 확인합니다.
압축 해제 필터(PEP 706)
filter="data"는 절대 경로 이름, ..을 통한 탈출, 대상 밖으로 나가는 링크, 장치 파일을 거부하고, set-user-ID 비트와 다른 사용자의 실행 권한을 제거합니다. Python 3.12부터, 그리고 3.8~3.11의 보안 릴리스에서 쓸 수 있으며 Python 3.14에서는 기본값이 됩니다.
자원 제한
필터는 크기를 제한하지 않습니다. 작은 압축 아카이브가 수백만 개의 멤버나 수 GB로 풀릴 수 있으므로, 압축을 풀기 전에 멤버 수와 전체 크기를 세어야 합니다.

단계별 공격 실행 흐름

단계 1

앱이 업로드된 아카이브를 받음

데이터 가져오기, 플러그인 설치기, CI 산출물 처리기가 사용자로부터 .tar.gz를 받습니다.

단계 2

멤버 이름이 폴더 밖을 가리킴

어떤 항목에 저장된 경로가 대상 폴더 위로 빠져나가거나 절대 경로입니다. ../outside.txt라는 멤버가 든 테스트 아카이브가 이 경우를 보여 줍니다.

단계 3

extractall이 해석된 경로에 씀

파일은 이름이 가리키는 곳에 놓이며, 대상 폴더 자체도 사용자가 보낸 job_id에서 정해질 수 있습니다.

단계 4

애플리케이션 파일이 바뀜

덮어쓴 설정, 템플릿, 스크립트는 애플리케이션이 다음에 불러올 때 실행되며, 이렇게 경로 조작이 코드 실행으로 이어집니다.

소스 코드 비교: 취약한 구현 vs 보안 패치

취약한 구현
# imports.py: 각 멤버가 저장된 이름이 가리키는 곳에 쓰인다
import tarfile
from pathlib import Path

UPLOAD_ROOT = Path("/srv/app/imports")


# 호출자가 고른 폴더, 필터도 크기 제한도 없음
def import_bundle(archive_path: str, job_id: str) -> Path:
    dest = UPLOAD_ROOT / job_id
    dest.mkdir(parents=True, exist_ok=True)
    with tarfile.open(archive_path) as tar:
        tar.extractall(dest)
    return dest
보안 강화 패치
# imports.py: data 필터, 서버가 이름 붙인 폴더, 크기 제한
import tarfile
import uuid
from pathlib import Path

UPLOAD_ROOT = Path("/srv/app/imports")
MAX_MEMBERS = 10_000
MAX_TOTAL_BYTES = 500 * 1024 * 1024


# 멤버나 바이트가 너무 많은 아카이브는 거부한다
def check_limits(tar: tarfile.TarFile) -> None:
    members = tar.getmembers()
    if len(members) > MAX_MEMBERS or sum(m.size for m in members) > MAX_TOTAL_BYTES:
        raise ValueError("archive too large")


# filter='data'가 .., 절대 경로, 바깥 링크, 장치 파일을 막는다
def import_bundle(archive_path: str) -> Path:
    dest = UPLOAD_ROOT / uuid.uuid4().hex
    dest.mkdir(parents=True)
    with tarfile.open(archive_path, mode="r:*") as tar:
        check_limits(tar)
        tar.extractall(dest, filter="data")
    return dest

엔지니어링 및 시스템 보안 강화 체크리스트

출처

← 전체 보안 디렉터리 보기 모든 취약점 가이드 →