사용자가 올린 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바이트) | 원조 형식. 지금도 대부분의 파서가 읽어요 |
| UStar | prefix 필드로 255바이트까지 | 링크 이름은 여전히 100바이트 |
| pax | x(이번 파일만)·g(이후 전부) 확장 헤더에 <길이> <키>=<값>\n 레코드 | POSIX pax 명세로 표준화 |
| GNU | L(긴 파일 이름)·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 문서의 추출 필터 절이 설명하는 이름 붙은 필터는 세 가지예요.
fully_trusted: 아무것도 바꾸지 않아요. 3.11 까지의 동작과 같아요.tar: 앞쪽/를 떼고, 절대 경로와 대상 밖으로 나가는 경로를 거부하고, setuid·setgid·sticky 와 그룹·기타 쓰기 비트를 지워요.data:tar에 더해 절대 경로나 대상 밖을 가리키는 링크, 장치 파일·파이프를 거부하고, 소유자 정보는 적용하지 않아요.
위 위험 유형에 맞춰 아카이브 9개(+정상 1개)를 만들고 세 필터로 각각 풀었어요. 대상 디렉터리를 임시 폴더 두 단계 아래에 두고, 바깥에 파일이 생겼는지와 풀린 파일의 권한을 확인했어요.
| 사례 | fully_trusted | tar | data |
|---|---|---|---|
① ../evil.txt | 밖에 생성 | 차단 (OutsideDestinationError) | 차단 (OutsideDestinationError) |
| ② 절대 경로 이름 | 그 절대 경로에 생성 | / 를 떼고 대상 안에 생성 | / 를 떼고 대상 안에 생성 |
③ 링크 lnk → .. + lnk/evil.txt | 밖에 생성 | 차단 (OutsideDestinationError) | 차단 (LinkOutsideDestinationError) |
| ④ 절대 경로 심볼릭 링크 | 생성 | 생성 | 차단 (AbsoluteLinkError) |
| ⑤ 바깥 파일로 가는 하드 링크 | 생성 | 생성 | 차단 (AbsoluteLinkError) |
| ⑥ setuid 4755 | 4755 그대로 | 755 로 낮춤 | 755 로 낮춤 |
| ⑦ FIFO | 생성 | 생성 | 차단 (SpecialFileError) |
| ⑧ 777 | 777 그대로 | 755 로 낮춤 | 755 로 낮춤 |
| ⑨ 같은 이름 두 번 | 뒤의 것으로 덮음 | 뒤의 것으로 덮음 | 뒤의 것으로 덮음 |
| 정상 파일 644 | 그대로 | 그대로 | 그대로 |
data 필터를 써도 같은 이름 덮어쓰기(⑨)는 막지 못해요
표로 보기
| 구분 | 거부 | 고쳐서 풀기 | 그대로 통과 |
|---|---|---|---|
| fully_trusted | 0 | 0 | 9 |
| tar | 2 | 3 | 4 |
| data | 5 | 3 | 1 |
눈여겨볼 점은 두 가지예요. 첫째, tar 필터는 링크가 어디를 가리키는지는 보지 않아요. ④·⑤처럼 바깥 파일로 가는 링크가 그대로 만들어지고, 하드 링크라면 그 링크에 쓰는 순간 바깥 원본이 바뀌어요. 데이터 묶음을 푼다면 data 를 고르는 게 맞아요. 둘째, ②는 거부가 아니라 "고쳐서 풀기"예요. 예외가 안 나니 로그만 봐서는 위험한 입력이 들어왔는지 모를 수 있어요.
버전별 기본값도 알아 두세요. 문서에 따르면 3.14 부터 기본 필터가 data 이고, 그 전에는 fully_trusted 와 같았어요. 3.12·3.13 에서 filter 를 생략하면 DeprecationWarning 만 나고 예전처럼 풀려요. 또 필터 기능이 3.12 이전 버전에 보안 업데이트로 들어간 경우가 있어서, 문서는 버전 번호 대신 hasattr(tarfile, "data_filter") 로 확인하라고 권해요.
data 필터가 막지 못하는 것 — 직접 더할 제한
tarfile 문서는 filter="data" 를 써도 "검사 없이 믿을 수 없는 파일을 풀기에는 맞지 않다" 고 분명히 적어요. 문서가 직접 꼽는 빈틈은 이래요.
- 서비스 거부 공격(파일 수·크기 폭탄)을 막지 않아요.
- 같은 파일이 여러 번 들어 있으면 뒤의 것이 앞의 것을 덮어요.
- 대소문자를 구분하지 않는 파일시스템(macOS 기본, 윈도우)에서는
README와readme가 서로를 가려요. - 추출하는 동안 누군가 대상 디렉터리를 바꾸는 경쟁 상황은 다루지 않아요.
그래서 실무에서는 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 를 내놨어요.
이 예시는 이름만 바꿨지만, size 를 다르게 읽게 만들면 블록 경계가 어긋나 그 뒤 목록 전체가 달라지는 완전형이 돼요. Astral 이 다른 구현에서 찾았다고 밝힌 유형도 비슷해요. pax 확장과 GNU L/K 를 섞었을 때의 적용 대상 혼동, g 헤더의 size 무시, 빈 값 레코드(삭제 표시) 오처리, 정수 필드를 언어 기본 함수로 읽어 공백이나 다른 숫자 문자를 받아 주는 경우예요.
tar-codec 이 해석 차이를 줄이는 방식 — 내 파서에도 쓸 원칙
tar-codec 의 설계는 한 문장으로 "애매하면 받아 주지 말고 거부한다"예요. Astral 글에서 확인한 원칙을 정리하면, 직접 아카이브 파서를 짜거나 고를 때 그대로 점검표로 쓸 수 있어요.
- 형식 갈래 고정: 첫 블록의 magic·version 으로 UStar/pax 계열인지 GNU 계열인지 정하고, 뒤에서 다른 계열 헤더가 나오면 거부해요. 실제 도구들은 계열을 섞어 쓰지 않으니 깨지는 정상 파일이 거의 없어요.
- 확장 중첩 제한: GNU 문서는
L/K를 여러 번 겹치는 것을 허용하지만, tar-codec 은 파일 하나에 각각 한 번씩만 받아요. - pax 레코드 상태 구분: 레코드를 "선언 안 됨·값 있음·삭제됨" 세 상태로 다루고, 삭제 때문에 경로가 비거나
g헤더가 모든 파일의size를 정하려는 식의 말이 안 되는 상태는 거부해요. - 층 분리: 블록을 읽는 층, 확장을 합쳐 파일 단위로 조립하는 층, 디스크에 쓰는 층을 나눴어요.
size처럼 블록 경계를 바꾸는 값은 가장 먼저 처리해요. - 실패 상태 고정: 입출력이나 검증이 한 번이라도 실패하면 상태 머신이 실패 상태로 가고 더 읽지 않아요.
- 추출은 디렉터리에 묶기: 디스크 쓰기는 cap-std 위에 올려, 모든 경로 해석을 대상 디렉터리 하나 아래로 묶었어요.
uv 0.13 부터 이 파서가 기본이고, 예전 구현으로 돌아가려면 UV_LEGACY_TAR_BACKEND=1 을 설정해요(uv 환경 변수 문서). Astral 은 tar 형식이 이미 여러 갈래로 쪼개져 있어서 해석 차이를 완전히 없앴다고 보장할 수는 없다고 덧붙였어요. 그러니 시스템 설계 쪽에서는 검사한 쪽이 만든 파일 목록을 그대로 써서 풀거나, 검사와 추출에 같은 라이브러리·같은 버전을 쓰는 구조가 가장 확실해요.
안전 장치의 속도 비용 — 5,000개 파일 실측
경로를 대상 안에 가두려면 파일마다 실제 경로를 다시 확인해야 해서 느려져요. 100바이트짜리 파일 5,000개(50개 하위 폴더)를 담은 5.1MB tar 를 필터별로 다섯 번씩 풀어 중간값을 쟀어요.
tar 필터에서 이미 36% 가 늘고, data 는 링크 대상 확인이 더해져 조금 더 늘어요. 비용은 파일 개수에 비례하지만, 이 정도 차이로 믿을 수 없는 입력에서 필터를 끄는 건 맞지 않아요.
운영체제도 영향을 줘요. 리눅스 5.6 부터 있는 openat2(2) 의 RESOLVE_BENEATH 는 커널이 "기준 디렉터리 아래가 아니면 경로 해석을 실패"시켜 줘요. 이런 호출이 없는 macOS 같은 곳에서는 디렉터리를 한 단계씩 열며 확인해야 해서, Astral 도 macOS 에서 안전한 추출이 눈에 띄게 느려진다고 밝혔어요.
상황별 설정 판단표
| 상황 | 고를 설정 | 이유 |
|---|---|---|
| 내가 만든 백업을 내 서버에 복원 | 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에서 도식과 코드로 단계별로 풀었어요.