광고
~ / posts / developer

tar 압축 해제 보안, 경로 순회·링크 탈출·파서 해석 차이 막는 법

믿을 수 없는 tar 는 경로·링크를 대상 디렉터리에 가두고, 개수·크기를 제한하고, 검사한 파서로 그대로 풀어야 안전해요. 파이썬 tarfile 필터 3종을 위험 사례 9개로 직접 시험한 결과와 pax 레코드 해석 차이 실험을 정리했어요.

사용자가 올린 tar.gz 를 서버에서 풀거나, 배포 파이프라인에서 남이 만든 아카이브를 받아 펼치는 코드는 생각보다 흔해요. 결론부터 말하면 믿을 수 없는 tar 는 ① 경로와 링크를 대상 디렉터리 안에 가두고(파이썬은 filter="data"), ② 파일 수·크기·중복 이름을 따로 제한하고, ③ 내용을 검사한 파서와 실제로 푸는 파서를 같게 맞춰야 해요. 앞의 두 가지는 설정으로 해결되지만, 세 번째는 설계 문제라 놓치기 쉬워요.

2026년 10월 uv 0.13 이 기본 tar 파서를 새로 만든 tar-codec 으로 바꾸면서 세 번째 문제, 즉 파서 간 해석 차이(parser differential) 가 다시 주목받았어요. 이 글은 그 소식 자체보다 "내 코드에서 tar 를 안전하게 풀려면 무엇을 고르나"를 다뤄요. 파이썬 3.13.7(Apple M1, macOS)에서 위험한 아카이브 9종을 직접 만들어 필터별로 풀어 본 결과, 필터가 못 막는 것, 해석 차이가 실제로 생기는 모습을 순서대로 정리했어요.

광고

tar 가 유난히 위험한 이유 — 하나가 아니라 여러 형식

tar 는 테이프 백업용으로 태어난 블록 형식이에요. 모든 블록이 512바이트이고, 헤더 블록 하나 뒤에 내용 블록이 이어지는 구조가 끝까지 반복돼요. 파일 전체를 설명하는 머리말이 없어서, 파서는 헤더를 하나씩 읽으며 "다음 몇 블록이 내용인가"를 계산해 나가요. 이 계산이 한 번 어긋나면 그 뒤의 모든 해석이 달라져요.

문제는 tar 가 사실상 여러 형식의 묶음이라는 점이에요. Astral 의 tar-codec 소개 글은 실무에서 의미 있는 갈래를 이렇게 나눠요.

갈래이름 길이 한계를 넘는 방법특징
UNIX V7넘지 못함(100바이트)원조 형식. 지금도 대부분의 파서가 읽어요
UStarprefix 필드로 255바이트까지링크 이름은 여전히 100바이트
paxx(이번 파일만)·g(이후 전부) 확장 헤더에 <길이> <키>=<값>\n 레코드POSIX pax 명세로 표준화
GNUL(긴 파일 이름)·K(긴 링크 이름) 전용 헤더표준 문서 대신 GNU tar 소스가 사실상 기준

블록 사이 패딩에 무엇이 들어가도 형식상 문제가 없고, 어느 갈래 규칙을 적용하느냐, 확장 헤더끼리 어떤 순서로 덮어쓰느냐에 따라 파서마다 다른 결과를 낼 여지가 많아요.

압축 해제 때 생기는 위험 유형 7가지

tar 관련 사고는 크게 두 층으로 나뉘어요. 하나는 "파일을 디스크에 쓸 때" 생기는 문제, 다른 하나는 "바이트를 해석할 때" 생기는 문제예요.

유형아카이브 안의 모습결과
경로 순회이름이 ../evil.txt대상 디렉터리 밖에 파일이 생겨요
절대 경로이름이 /etc/... 로 시작대상과 상관없는 위치에 써요
링크 탈출심볼릭 링크 lnk → .. 뒤에 lnk/evil.txt링크를 타고 밖으로 써요. 경로 문자열만 보는 검사로는 못 잡아요
권한·특수 파일setuid 비트, 누구나 쓰기 가능(777), FIFO·장치 파일권한 상승 발판이나 읽기 멈춤
같은 이름 덮어쓰기같은 경로가 두 번뒤의 것이 앞의 것을 덮어요. 앞의 것만 검사했다면 우회돼요
자원 고갈파일 수십만 개, 거대한 크기 선언디스크·inode·메모리 고갈
파서 해석 차이확장 헤더를 애매하게 조합검사한 목록과 실제로 풀린 목록이 달라요

권한 비트가 왜 위험한지 감이 안 오면 4755 같은 숫자를 chmod 권한 계산기에 넣어 보세요. 맨 앞 4 가 setuid 라서, 그 파일을 실행하면 소유자 권한으로 돌아요. 숫자 읽는 법은 리눅스 chmod 755 644 의미 글에 정리해 두었어요.

파이썬 tarfile 필터 3종, 위험 사례 9개로 직접 시험

파이썬 3.12 부터 extractall() 과 extract() 에 filter 인자가 생겼어요. tarfile 문서의 추출 필터 절이 설명하는 이름 붙은 필터는 세 가지예요.

위 위험 유형에 맞춰 아카이브 9개(+정상 1개)를 만들고 세 필터로 각각 풀었어요. 대상 디렉터리를 임시 폴더 두 단계 아래에 두고, 바깥에 파일이 생겼는지와 풀린 파일의 권한을 확인했어요.

사례fully_trustedtardata
① ../evil.txt밖에 생성차단 (OutsideDestinationError)차단 (OutsideDestinationError)
② 절대 경로 이름그 절대 경로에 생성/ 를 떼고 대상 안에 생성/ 를 떼고 대상 안에 생성
③ 링크 lnk → .. + lnk/evil.txt밖에 생성차단 (OutsideDestinationError)차단 (LinkOutsideDestinationError)
④ 절대 경로 심볼릭 링크생성생성차단 (AbsoluteLinkError)
⑤ 바깥 파일로 가는 하드 링크생성생성차단 (AbsoluteLinkError)
⑥ setuid 47554755 그대로755 로 낮춤755 로 낮춤
⑦ FIFO생성생성차단 (SpecialFileError)
⑧ 777777 그대로755 로 낮춤755 로 낮춤
⑨ 같은 이름 두 번뒤의 것으로 덮음뒤의 것으로 덮음뒤의 것으로 덮음
정상 파일 644그대로그대로그대로
위험 사례 9개를 필터별로 풀었을 때 처리 결과
거부고쳐서 풀기그대로 통과
02.557.510fully_trusted · 거부 0개fully_trusted · 고쳐서 풀기 0개fully_trusted · 그대로 통과 9개fully_trustedtar · 거부 2개tar · 고쳐서 풀기 3개tar · 그대로 통과 4개tardata · 거부 5개data · 고쳐서 풀기 3개data · 그대로 통과 1개data

data 필터를 써도 같은 이름 덮어쓰기(⑨)는 막지 못해요

단위: 개 · 자료: 파이썬 3.13.7 tarfile 로 직접 만든 아카이브를 풀어 확인 (2026-10-28)
표로 보기
구분거부고쳐서 풀기그대로 통과
fully_trusted009
tar234
data531

눈여겨볼 점은 두 가지예요. 첫째, tar 필터는 링크가 어디를 가리키는지는 보지 않아요. ④·⑤처럼 바깥 파일로 가는 링크가 그대로 만들어지고, 하드 링크라면 그 링크에 쓰는 순간 바깥 원본이 바뀌어요. 데이터 묶음을 푼다면 data 를 고르는 게 맞아요. 둘째, ②는 거부가 아니라 "고쳐서 풀기"예요. 예외가 안 나니 로그만 봐서는 위험한 입력이 들어왔는지 모를 수 있어요.

버전별 기본값도 알아 두세요. 문서에 따르면 3.14 부터 기본 필터가 data 이고, 그 전에는 fully_trusted 와 같았어요. 3.12·3.13 에서 filter 를 생략하면 DeprecationWarning 만 나고 예전처럼 풀려요. 또 필터 기능이 3.12 이전 버전에 보안 업데이트로 들어간 경우가 있어서, 문서는 버전 번호 대신 hasattr(tarfile, "data_filter") 로 확인하라고 권해요.

data 필터가 막지 못하는 것 — 직접 더할 제한

tarfile 문서는 filter="data" 를 써도 "검사 없이 믿을 수 없는 파일을 풀기에는 맞지 않다" 고 분명히 적어요. 문서가 직접 꼽는 빈틈은 이래요.

그래서 실무에서는 data_filter 를 감싼 상태 있는 필터를 하나 두는 편이에요. 아래 코드는 링크를 아예 거부하고, 대소문자를 무시한 중복 이름과 개수·크기 한도를 검사해요. 파이썬 3.13.7 에서 a.txt, b.txt 는 통과, README, readme 와 conf, conf 는 각각 "같은 이름 두 번"으로 멈추는 것을 확인했어요.

import tarfile

class SafeFilter:
    def __init__(self, max_files=10_000, max_total=1 << 30, max_member=256 << 20):
        self.max_files, self.max_total, self.max_member = max_files, max_total, max_member
        self.count = self.total = 0
        self.seen = set()

    def __call__(self, member, path):
        member = tarfile.data_filter(member, path)   # 경로·링크·권한은 data 필터에 맡김
        if member.issym() or member.islnk():
            raise ValueError(f"링크 거부: {member.name}")
        key = member.name.casefold()
        if key in self.seen:
            raise ValueError(f"같은 이름 두 번: {member.name}")
        self.seen.add(key)
        self.count += 1
        self.total += member.size
        if member.size > self.max_member or self.count > self.max_files or self.total > self.max_total:
            raise ValueError(f"한도 초과: {member.name}")
        return member

# 반드시 새 임시 디렉터리에 풀고, 예외가 나면 디렉터리째 지워요
# tar.extractall(tmpdir, filter=SafeFilter())

필터는 파일 하나씩 불리기 때문에, 중간에 예외가 나면 그 전 파일은 이미 디스크에 있어요. 그래서 새 빈 임시 디렉터리에 풀고 실패하면 통째로 지우는 것이 기본이에요. 이미 있는 디렉터리에 풀면, 미리 심어 둔 링크를 타고 밖으로 쓰는 공격에도 노출돼요. max_total 은 아카이브 헤더가 선언한 크기의 합으로 세요. gzip 단계의 압축 폭탄은 Content-Encoding 압축 해제 보안 글의 크기 제한 방식과 같은 원리로 다루면 돼요.

파서 해석 차이란 — pax 레코드 하나로 재현

여기까지는 "푸는 코드"의 문제였어요. 파서 해석 차이는 결이 달라요. 경로가 멀쩡하고 링크도 없는데, 두 프로그램이 같은 파일을 서로 다른 목록으로 읽는 현상이에요. 패키지 저장소가 업로드된 아카이브를 열어 검사하고, 설치기가 같은 파일을 다시 열어 풀 때 특히 문제가 돼요. Astral 은 이것을 두 종류로 나눠요. 특정 파서에서만 파일이 더 생기는 덧붙이기형(append), 두 파서가 완전히 다른 내용을 보는 완전형(full) 이에요.

가장 이해하기 쉬운 원인은 pax 레코드를 자르는 방식이에요. 레코드는 <길이> <키>=<값>\n 꼴이고, 맨 앞 길이는 길이 숫자 자신까지 포함한 레코드 전체 바이트 수예요. 값 안에 줄바꿈이 들어가도 되기 때문에, 줄바꿈으로 자르면 틀려요. 이걸 직접 재현해 봤어요. 파일 헤더의 이름은 innocent.py 로 두고, pax 확장 헤더에 comment 레코드 하나만 넣되 그 값에 줄바꿈과 16 path=evil.py 를 담았어요.

import io, re, tarfile

buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w", format=tarfile.PAX_FORMAT) as t:
    data = b"print('hi')\n"
    ti = tarfile.TarInfo("innocent.py")
    ti.size = len(data)
    ti.pax_headers = {"comment": "note\n16 path=evil.py"}
    t.addfile(ti, io.BytesIO(data))
raw = buf.getvalue()

size = int(raw[124:136].split(b"\0")[0], 8)   # 첫 헤더(pax 확장 헤더)의 크기 필드
pax = raw[512:512 + size]
print(pax)                                    # b'32 comment=note\n16 path=evil.py\n'

with tarfile.open(fileobj=io.BytesIO(raw)) as t:
    print("tarfile:", t.getnames())           # ['innocent.py']

naive = {}
for line in pax.decode().split("\n"):         # 잘못된 방식: 줄바꿈으로 자르기
    m = re.match(r"(\d+) ([^=]+)=(.*)", line)
    if m:
        naive[m.group(2)] = m.group(3)
print("naive:", naive.get("path", "innocent.py"))   # evil.py

파이썬 tarfile 은 길이 32 만큼을 한 레코드로 읽어 comment 값으로 처리하고, 이름은 헤더의 innocent.py 를 썼어요. macOS 기본 tar(bsdtar 3.5.3, libarchive 3.7.4)로 목록을 봐도 innocent.py 였어요. 반면 줄바꿈으로 자르는 단순 구현은 두 번째 줄을 별도 path 레코드로 보고 evil.py 를 내놨어요.

같은 32바이트 pax 레코드를 길이로 자르는 파서는 comment 값 하나로 읽어 파일 이름이 innocent.py 로 남고, 줄바꿈으로 자르는 파서는 두 줄로 나눠 path 레코드가 이름을 evil.py 로 덮어쓰는 비교 그림
검사기와 설치기가 이렇게 다르게 읽으면 검사 결과가 쓸모없어져요

이 예시는 이름만 바꿨지만, size 를 다르게 읽게 만들면 블록 경계가 어긋나 그 뒤 목록 전체가 달라지는 완전형이 돼요. Astral 이 다른 구현에서 찾았다고 밝힌 유형도 비슷해요. pax 확장과 GNU L/K 를 섞었을 때의 적용 대상 혼동, g 헤더의 size 무시, 빈 값 레코드(삭제 표시) 오처리, 정수 필드를 언어 기본 함수로 읽어 공백이나 다른 숫자 문자를 받아 주는 경우예요.

광고
광고

tar-codec 이 해석 차이를 줄이는 방식 — 내 파서에도 쓸 원칙

tar-codec 의 설계는 한 문장으로 "애매하면 받아 주지 말고 거부한다"예요. Astral 글에서 확인한 원칙을 정리하면, 직접 아카이브 파서를 짜거나 고를 때 그대로 점검표로 쓸 수 있어요.

  1. 형식 갈래 고정: 첫 블록의 magic·version 으로 UStar/pax 계열인지 GNU 계열인지 정하고, 뒤에서 다른 계열 헤더가 나오면 거부해요. 실제 도구들은 계열을 섞어 쓰지 않으니 깨지는 정상 파일이 거의 없어요.
  2. 확장 중첩 제한: GNU 문서는 L/K 를 여러 번 겹치는 것을 허용하지만, tar-codec 은 파일 하나에 각각 한 번씩만 받아요.
  3. pax 레코드 상태 구분: 레코드를 "선언 안 됨·값 있음·삭제됨" 세 상태로 다루고, 삭제 때문에 경로가 비거나 g 헤더가 모든 파일의 size 를 정하려는 식의 말이 안 되는 상태는 거부해요.
  4. 층 분리: 블록을 읽는 층, 확장을 합쳐 파일 단위로 조립하는 층, 디스크에 쓰는 층을 나눴어요. size 처럼 블록 경계를 바꾸는 값은 가장 먼저 처리해요.
  5. 실패 상태 고정: 입출력이나 검증이 한 번이라도 실패하면 상태 머신이 실패 상태로 가고 더 읽지 않아요.
  6. 추출은 디렉터리에 묶기: 디스크 쓰기는 cap-std 위에 올려, 모든 경로 해석을 대상 디렉터리 하나 아래로 묶었어요.

uv 0.13 부터 이 파서가 기본이고, 예전 구현으로 돌아가려면 UV_LEGACY_TAR_BACKEND=1 을 설정해요(uv 환경 변수 문서). Astral 은 tar 형식이 이미 여러 갈래로 쪼개져 있어서 해석 차이를 완전히 없앴다고 보장할 수는 없다고 덧붙였어요. 그러니 시스템 설계 쪽에서는 검사한 쪽이 만든 파일 목록을 그대로 써서 풀거나, 검사와 추출에 같은 라이브러리·같은 버전을 쓰는 구조가 가장 확실해요.

안전 장치의 속도 비용 — 5,000개 파일 실측

경로를 대상 안에 가두려면 파일마다 실제 경로를 다시 확인해야 해서 느려져요. 100바이트짜리 파일 5,000개(50개 하위 폴더)를 담은 5.1MB tar 를 필터별로 다섯 번씩 풀어 중간값을 쟀어요.

작은 파일 5000개 tar 추출 시간 (5회 중간값)
fully_trustedfully_trusted · 4.1초4.1초tartar · 5.6초5.6초datadata · 5.9초5.9초

data 필터가 필터 없음보다 약 44% 느렸어요. 파일이 크고 적으면 차이는 줄어들어요

단위: 초 · 자료: 파이썬 3.13.7, Apple M1, macOS APFS에서 직접 측정 (2026-10-28)
표로 보기
구분추출 시간
fully_trusted4.1
tar5.6
data5.9

tar 필터에서 이미 36% 가 늘고, data 는 링크 대상 확인이 더해져 조금 더 늘어요. 비용은 파일 개수에 비례하지만, 이 정도 차이로 믿을 수 없는 입력에서 필터를 끄는 건 맞지 않아요.

운영체제도 영향을 줘요. 리눅스 5.6 부터 있는 openat2(2) 의 RESOLVE_BENEATH 는 커널이 "기준 디렉터리 아래가 아니면 경로 해석을 실패"시켜 줘요. 이런 호출이 없는 macOS 같은 곳에서는 디렉터리를 한 단계씩 열며 확인해야 해서, Astral 도 macOS 에서 안전한 추출이 눈에 띄게 느려진다고 밝혔어요.

상황별 설정 판단표

믿을 수 없는 tar 를 풀 때 출처·체크섬 확인, 새 빈 임시 디렉터리, 경로·링크 가두기, 개수·크기·이름 제한, 링크·특수 파일 금지, 검사한 파서로 그대로 풀기 여섯 단계를 순서대로 보여 주는 점검표
1~2단계는 준비, 3~5단계는 설정, 6단계는 설계예요
상황고를 설정이유
내가 만든 백업을 내 서버에 복원filter="tar" 또는 fully_trusted링크·권한 보존이 필요하고 출처를 알아요
사용자가 올린 압축 파일 처리SafeFilter + 새 임시 디렉터리 + OS 자원 제한경로·링크·폭탄·중복을 모두 가정해야 해요
데이터셋·모델 파일 내려받기filter="data" + 체크섬 확인링크·특수 파일이 필요 없는 묶음이에요
저장소에서 검사 후 다른 곳에서 설치검사·설치 파서 통일, 모호한 헤더 거부해석 차이가 검사를 우회해요
소스 배포본(sdist) 설치출처 신뢰 판단이 먼저설치 과정에서 빌드 코드가 실행돼요

마지막 줄이 중요해요. Astral 도 짚었듯 sdist 는 설치할 때 빌드 코드를 실행하므로, 악의적인 sdist 를 설치하는 순간 이미 코드 실행이에요. 파서 안전은 출처 판단을 대신하지 못해요. 설치된 패키지가 시작할 때 몰래 코드를 실행하는 경로는 파이썬 .pth 파일 실행 원리 글에서 따로 다뤘어요.

광고
광고

자주 묻는 질문

파이썬 3.11 이하에서는 어떻게 해야 하나요?

먼저 hasattr(tarfile, "data_filter") 로 확인해요. 일부 배포판·패치 버전에는 보안 업데이트로 필터가 들어가 있어요. 없으면 getmembers() 를 돌며 이름이 절대 경로인지, os.path.realpath(os.path.join(dest, name)) 가 대상 안에 있는지, 링크 대상이 대상 안인지 직접 검사해야 하는데, 경계 사례가 많아서 가능하면 3.12 이상으로 올리는 편이 안전해요.

zip 파일도 같은 문제가 있나요?

경로 순회(Zip Slip)와 압축 폭탄은 zip 에도 그대로 있어요. 파서 해석 차이도 생길 수 있는데, zip 은 앞쪽 로컬 헤더와 끝쪽 중앙 디렉터리 두 곳에 파일 목록이 있어서 어느 쪽을 믿느냐가 대표적인 원인이에요. Astral 도 uv 의 zip 파서를 같은 이유로 바꾸는 작업을 하고 있다고 밝혔어요.

tar 명령으로 풀면 안전한가요?

이번에 확인한 macOS 기본 tar(bsdtar 3.5.3)는 ../up.txt 를 "Path contains '..'" 오류로 거부했어요. 하지만 도구마다, 옵션마다 동작이 달라서 링크·권한·자원 제한까지 보장한다고 보기는 어려워요. 자동화에서 남이 만든 아카이브를 푼다면 컨테이너나 권한 없는 사용자로 격리해서 푸는 편이 나아요.

필터가 거부했을 때 어떤 예외를 잡아야 하나요?

필터 거부는 모두 tarfile.FilterError 의 하위 클래스예요. 경로 문제는 OutsideDestinationError·AbsolutePathError, 링크 문제는 LinkOutsideDestinationError·AbsoluteLinkError, 장치 파일은 SpecialFileError 로 나와요. 직접 만든 필터에서 던진 ValueError 같은 다른 예외는 errorlevel 과 상관없이 그대로 올라오니, 둘 다 실패로 처리하면 돼요.

uv 0.13 으로 올린 뒤 sdist 설치가 실패하면요?

새 파서가 계열이 섞인 헤더나 말이 안 되는 pax 상태를 거부했을 가능성이 있어요. 급하면 UV_LEGACY_TAR_BACKEND=1 로 예전 파서를 쓸 수 있지만, 그 아카이브가 왜 거부됐는지 패키지 관리자에게 알리는 것이 근본 해결이에요. 정상 도구로 만든 tar 는 계열을 섞지 않아서 대부분 영향이 없어요.

정리

tar 안전 추출은 세 층으로 나눠 봐요. 디스크에 쓸 때는 경로와 링크를 대상 디렉터리 안에 가두고(파이썬은 filter="data", 3.14 부터 기본), 파일 수·크기·중복 이름 제한을 직접 더해요. 위 실험에서 data 필터는 위험 사례 9개 중 8개를 처리했고, 같은 이름 덮어쓰기와 자원 고갈은 남았어요. 마지막으로 검사하는 쪽과 푸는 쪽이 같은 바이트를 같은 목록으로 읽도록 파서를 통일하거나, 모호한 입력을 거부하는 파서를 써야 해요.

권한 숫자가 헷갈리면 chmod 권한 계산기, 받은 파일을 검증할 때는 해시 생성기를 함께 쓰면 편해요.

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 Tailscale SSH 취약점·uv tar-codec·Ghostty에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#tar 압축 해제 보안#tarfile filter data#경로 순회 취약점#심볼릭 링크 탈출#parser differential#pax 확장 헤더#tar-codec#안전한 압축 해제
← 이전 글스프링 도메인 이벤트 설정 고르기, 리스너 단계와 커넥션 풀 계산다음 글 →테라폼 parallelism 값 고르는 법, depends_on·create_before_destroy 판단표
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →