●
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
엔지니어링 및 시스템 보안 강화 체크리스트
- 모든
extractall()과extract()호출에filter="data"를 넘기고, Bandit B202나 Semgrep 규칙이filter=없는 호출을 찾으면 CI를 실패시킵니다. - UUID처럼 서버가 이름을 붙인 새 폴더에 풀고, 요청 데이터로 만든 경로에는 절대 풀지 않습니다.
- 압축을 풀기 전에 멤버 수와 풀린 뒤의 전체 크기를 확인하고, 한도를 넘는 아카이브는 거부합니다.
- 압축 해제 작업은 애플리케이션 코드나 설정에 쓰기 권한이 없는 사용자로 실행합니다.
../outside.txt와 절대 경로 멤버가 든 아카이브로tarfile.FilterError를 기대하는 CI 테스트를 추가합니다.