
uv는 0.13부터 파이썬 소스 배포판을 읽을 때 새 Rust 라이브러리 tar-codec을 기본으로 사용한다. Astral에 따르면 이전 구현보다 파싱 속도가 약 두 배 빨라졌으며, 서로 다른 파서가 같은 tar 파일을 다르게 해석하는 문제와 관련 보안 위험도 줄이도록 설계했다.
tar-codec은 기존 astral-tokio-tar를 대체한다. 이 라이브러리는 0.12부터 미리보기 기능으로 제공됐고, 0.13에서는 기본값이 됐다. 이전 동작으로 되돌리려면 UV_LEGACY_TAR_BACKEND=1을 설정한다. Astral은 tar-codec의 목적과 tar 형식의 복잡성, 새 파서를 만든 이유를 설명했다.
uv가 새 tar 파서를 만든 이유
uv는 패키지 인덱스 같은 출처에서 설치 가능한 파이썬 배포판을 받아 설치한다. 배포판에는 크게 ZIP 아카이브인 휠과 tar 아카이브인 소스 배포판(sdist)이 있다. 휠은 대체로 정해진 구조와 메타데이터를 갖춘 파일 묶음이다. 소스 배포판은 릴리스 시점의 소스 트리를 담으며, 설치 과정에서 빌드 도구가 코드를 실행할 수 있다. 따라서 소스 배포판은 유연하지만 필요한 도구나 의존성이 없으면 설치가 실패할 수 있고, 악성 배포판이 설치 과정에서 임의 코드를 실행할 위험도 있다.
0.13 이전 uv는 tar 파싱과 uv build의 tar 생성에 astral-tokio-tar를 썼다. 이 라이브러리는 여러 포크를 거슬러 올라가면 composefs/tar-rs의 비동기식 변형에 이른다. Astral은 약 1년 전 astral-tokio-tar에 보안 보고가 여러 건 들어온 뒤 압축·아카이브 처리 계층을 조사했고, 추가 문제를 발견했다고 밝혔다.
핵심 우려는 파서 간 차이(parser differential)였다. 같은 tar 파일을 uv, pip 등 도구가 서로 다른 내용으로 해석할 수 있다는 뜻이다. 예를 들어 패키지 인덱스에서는 안전해 보이는 파일이 설치 도구에서 위험한 방식으로 풀릴 수 있다. 공격자는 특정 파서에서 파일을 추가하거나 기존 파일 내용을 덧붙이는 ‘append’ 차이를 만들 수 있고, 파서마다 압축 해제 결과 전체를 다르게 만드는 ‘full’ 차이도 만들 수 있다.
Astral은 소스 배포판이 빌드 과정에서 임의 코드를 실행할 수 있다는 점 때문에 파이썬 패키징에서는 tar 해석 차이를 상대적으로 작은 우려로 여기는 경우가 있다고 설명했다. 공격자가 악성 소스 배포판을 이용한다면 디스크에 파일을 쓰거나 코드를 실행할 더 직접적인 경로가 있기 때문이다. 그럼에도 발견된 문제를 우려해 기존 라이브러리의 차이를 없애는 작업에 착수했다. Astral은 tokio-tar 계열이 설계상 지나치게 관대해 제약을 덧붙이는 방식은 번거롭고 오류가 나기 쉽다고 판단했다. 성능 목표를 달성하려면 기존 코드도 사실상 다시 써야 했다. 이에 tar 형식을 처음부터 분석해 새 구현인 tar-codec을 만들었다.
tar 형식이 파서 차이를 만드는 방식
tar는 하나의 형식이라기보다 여러 형식의 계열이다. 실무에서 중요한 것은 UNIX V7, UStar/pax, GNU tar다. V7은 오래된 원형으로, 현재도 많은 구현이 읽지만 파일당 크기가 약 8 GiB, 경로 길이가 100바이트로 제한된다. UStar는 경로 한도를 255바이트로 늘리지만 파일 크기 제한과 링크 이름 100바이트 제한은 유지한다. pax는 확장 헤더를 이용해 파일 크기와 경로·링크 이름 길이 제한을 사실상 없앤다. GNU tar도 크기와 경로 길이를 늘리지만 pax와는 다른 방식으로 확장한다.
모든 tar 블록은 512바이트다. 헤더 블록은 뒤따르는 구성원의 경로와 크기 같은 메타데이터를 담고, 데이터 블록은 파일 내용을 담는다. 파일 내용이 블록 크기의 배수가 아니면 마지막 블록을 채운다. 관례상 빈 공간은 NUL 바이트로 채우지만 형식은 그 내용에 엄격한 제한을 두지 않는다. 초기화되지 않은 메모리나 임의 데이터가 들어갈 수도 있어, 서로 다른 파서가 빈 공간을 다르게 처리하면 해석 차이가 생길 여지가 있다.
UStar는 V7 헤더의 일부 공간을 재활용해 경로 길이를 늘린다. pax는 x(로컬)와 g(전역) 확장 헤더를 사용한다. 확장 헤더에는 <길이> <키워드>=<값>\n 형태의 레코드가 들어간다. 레코드 값은 해당 구성원의 헤더 값을 덮어쓴다. 로컬 헤더는 다음 구성원에만 적용되고 전역 헤더는 뒤따르는 모든 구성원에 적용되며, 로컬 값은 전역 값보다 우선한다. pax 레코드는 값이 빈 문자열인 경우에도 단순히 미지정 상태가 되는 것이 아니라 이전 값을 명시적으로 삭제하는 ‘tombstone’ 상태를 만들 수 있다.
GNU tar는 숫자 필드에서 첫 바이트의 최상위 비트가 설정되면 뒤의 바이트를 8진수 대신 2진수로 읽는 base-256 인코딩을 사용한다. 이를 통해 12바이트 크기 필드의 실질 한도를 2**80바이트, 즉 1 yobibyte까지 늘린다. 파일명과 링크 이름을 덮어쓰는 데는 pax의 일반적인 키-값 레코드 대신 L과 K 유형 플래그를 사용한다. GNU 확장은 여러 개를 쌓을 수 있다.
이처럼 tar 계열마다 확장 정보와 우선순위 규칙이 다르다. 일부 구현은 pax와 GNU 확장을 한 상태 기계에 섞어 처리한다. 예컨대 GNU의 L 또는 K가 다음 파일에 적용돼야 하는 상황에서 pax 로컬 확장 헤더가 끼어들면, 파서마다 두 확장을 합치거나 한쪽만 적용하거나 엉뚱한 헤더에 적용할 수 있다. 이런 차이는 압축 해제 결과가 달라지는 보안 문제로 이어질 수 있다.
tar-codec의 처리 원칙과 제약
tar-codec은 첫 블록의 식별 정보(magic과 version)를 확인해 아카이브를 UStar/pax 계열 또는 GNU 계열로 분류한 뒤, 나머지 파싱을 그 계열에 고정한다. 개별 헤더가 서로 다른 계열을 가리키면 아카이브를 거부한다. Astral은 실제 tar 생성기가 계열을 섞지 않으므로 이 제한이 큰 호환성 문제를 일으키지 않을 것으로 본다.
GNU 확장에 대해서는 문서에 적힌 것보다 엄격하게 처리한다. L과 K는 어느 순서로든 허용하지만, 한 구성원에 같은 종류의 확장이 여러 번 붙는 것은 허용하지 않는다. pax의 로컬·전역 헤더는 상태를 엄격히 추적한다. pax 확장은 스트림 중간 어디에나 나타날 수 있고, 각 레코드는 미지정, 활성, 삭제(tombstone) 상태를 가질 수 있다. 삭제된 path처럼 논리적으로 성립하지만 처리 결과가 모호한 경우, 빈 경로와 같은 상태를 거부하도록 한다. 전역 확장 헤더가 모든 구성원에 size 같은 핵심 상태를 지정하는 경우도 거부하려고 한다.
구현은 두 계층으로 나뉜다. tar-framing은 블록 스트림을 프레임으로 바꾼다. 물리 API는 상태 기계에 따라 블록을 하나씩 내보내고, 논리 API는 이를 구성원 단위로 조립한다. 조립된 구성원에는 확장 상태가 반영된 헤더, 파일 내용, GNU L·K 같은 프레이밍 외 확장 상태가 들어간다. tar-codec은 이 논리 API를 이용해 내용을 메모리나 디스크에 추출할 수 있는 형태로 제공한다.
디스크 추출은 파싱과 별개의 위험을 낳는다. 아카이브마다 자체 추출 구현을 갖는 경우가 많고, 경로와 링크 처리에서 구현별 차이가 생긴다. Astral은 여러 형식이 공통으로 사용할 수 있는 Archive와 ArchiveBuilder 트레이트를 제공하는 archive-trait을 만들었다. 파일시스템 추출은 cap-std를 기반으로 하며, 추출 위치를 한 디렉터리 아래로 제한해 링크 및 경로 순회 취약점의 상당 부분을 피하도록 설계했다.
Astral은 tar 파서를 차이 공격에 견디게 만드는 일이 예상보다 어렵다고 설명한다. 형식별 상태 기계와 메타데이터 우선순위가 복잡하고, 문서나 기준 구현에서 규칙이 명확하지 않은 경우도 있다. 구현 호환성을 위해 pax 규격을 일부 엄격하게 적용하지 않은 사례도 있다. pax는 레코드 값이 UTF-8이어야 한다고 명시하지만 GNU tar와 star는 과거 일부 업체별 레코드에 원시 바이너리를 기록했다. UStar와 pax는 uname, gname을 NUL 종료 문자열로 규정해 31바이트까지 허용하지만, CPython의 tarfile은 32바이트도 받아들인다. mode, uid, gid, mtime이 모두 NUL인 아카이브도 있어 tar-codec은 임의 기본값을 정하는 대신 값을 ‘누락’으로 추적한다.
조사 과정에서 동일한 입력을 서로 다르게 처리하게 만드는 여러 원시 요소도 발견했다고 Astral은 밝혔다. pax와 GNU 상태를 혼동하거나, pax 전역 헤더와 tombstone 우선순위를 잘못 처리하거나, 레코드의 길이 접두사를 무시하고 줄바꿈만으로 나누는 사례가 그 예다. pax 값 자체에 줄바꿈이 들어갈 수 있으므로 마지막 방식은 안전하지 않다. 정수 필드를 읽는 방식도 구현 언어의 변환 함수에 따라 숫자 표기, 공백 처리 등이 달라져 서로 다른 결과를 낼 수 있다. Astral은 이 가운데 일부를 보안 취약점으로 공개하는 절차를 진행 중이라고 설명했다.
Astral은 성능 최적화 자체는 어려운 부분이 아니었다고 밝혔다. 체크섬 계산과 pax 레코드 길이 계산을 개선하고, 할당을 재사용하며 작업을 묶고 버퍼링해 파싱·생성 성능을 높였다. 반면 안전한 추출은 비용이 따른다. cap-std의 디렉터리 권한 추적은 파일시스템 작업에 측정 가능한 오버헤드를 더한다. 특히 openat2(2)나 이에 준하는 시스템 호출이 없는 호스트에서는 경로의 각 디렉터리 구성 요소를 열어야 해 비용이 커지며, Astral은 macOS에서 추출 성능에 부정적 영향이 있다고 밝혔다.
Astral은 tar-codec이 입력을 관대하게 받아들이기보다 보수적으로 거부하고, 구성원 프레이밍부터 스트리밍과 추출까지 제약을 단계적으로 적용한다고 설명한다. 다만 tar 형식이 여러 갈래로 나뉘고 규격이 충분히 명확하지 않아 파서 간 차이가 전혀 없다고 단언할 수는 없으며, 앞으로 새로운 차이가 발견될 가능성도 인정했다. 회사는 현재 설계가 그런 문제를 발견하고 대응하는 데 적합하다고 본다.
현재 tar-codec은 uv의 필요에는 대체로 기능이 갖춰졌지만, Astral은 희귀한 tar 기능인 희소 파일과 업체별 pax 레코드 제어 지원을 필요에 따라 검토할 예정이다. 다른 구현의 정확성을 개선하는 작업도 커뮤니티와 진행할 계획이며, Harmen Stoppel의 tar-minefield 프로젝트를 구현 간 차이를 시험하고 엄격한 해석으로 맞추는 유망한 수단으로 꼽았다. Astral은 비슷한 이유로 uv의 ZIP 파서 교체도 검토하고 있으며, 관련 작업의 교훈과 uv에 미치는 이점을 추후 공유하겠다고 밝혔다.
참고 자료: tar-codec 발표, uv 저장소, tar-codec 저장소, TARmageddon, tar-minefield, pax 규격.
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요