심볼릭 링크를 막았더니 정상 설치가 깨졌다: uv 0.12.15가 보안 검사를 되돌린 이유

Python 패키지 관리 도구 uv 0.12.15는 전날 0.12.14에 들어간 심볼릭 링크 검사를 되돌렸습니다. 원래 검사의 목적은 타당했습니다. Wheel을 기존 디렉터리에 합칠 때 대상 경로 안의 링크를 따라가면, 패키지 파일이 가상환경이나 지정한 설치 루트 밖에 써질 수 있습니다. 문제는 방어 코드가 "경계 밖으로 나가는 링크"와 "같은 설치 prefix 안에서 경로만 정리한 링크"를 구분하지 않았다는 점입니다.

그 결과 두 종류의 정상 설치가 함께 막혔습니다. 첫째, uv pip install --target=.처럼 현재 디렉터리를 설치 대상으로 쓰는 명령이 대부분의 Wheel에서 실패했습니다. 둘째, 공식 Python Docker 이미지에서 man page를 포함한 패키지를 시스템에 설치하면 /usr/local/man -> share/man 링크 때문에 전체 설치가 중단됐습니다. 0.12.15는 이 검사를 되돌려 두 회귀를 고쳤습니다.

0.12.14가 막으려던 것

Wheel은 Python 코드만 담지 않습니다. .data 디렉터리 아래에는 스크립트, 헤더, 일반 데이터처럼 설치 위치가 다른 파일이 들어갈 수 있습니다. 설치 도구는 Wheel을 푼 뒤 각 하위 트리를 Python 설치 scheme의 목적지로 옮깁니다. 이때 목적지 안에 미리 만들어진 디렉터리 심볼릭 링크가 있으면 파일 쓰기가 예상한 루트 밖으로 빠질 수 있습니다.

uv PR #21569는 이 경로를 ValidatedWheelDestination이라는 검증된 대상으로 감싸고, 하위 경로를 걸으며 심볼릭 링크를 만나면 설치를 거부했습니다. 외부 임시 디렉터리를 가리키는 링크를 site-packages/foo에 심어 둔 테스트, 더 깊은 중첩 링크, .data와 헤더 목적지 링크를 다루는 테스트도 추가했습니다. 단순한 오류 문구 변경이 아니라 파일 배치 경계를 강화하려는 코드였습니다.

Wheel 데이터 파일이 공식 Python 이미지의 prefix 내부 man 심볼릭 링크를 만나 uv 0.12.14에서 전체 설치가 거부되는 경로

그림 1. 0.12.14의 링크 차단은 설치 루트 안에 머무는 정상 링크도 외부 이탈과 함께 막았다. 출처: github.com/astral-sh/uv/issues/21692.

하지만 링크의 존재 자체가 루트 이탈을 뜻하지는 않습니다. Debian 계열 공식 Python 이미지의 /usr/local/man/usr/local/share/man을 가리키는 상대 링크입니다. 최종 경로는 여전히 /usr/local 아래에 있습니다. 0.12.14는 이 정상 레이아웃도 "symlinked directory"로 분류했습니다. Wheel에 man page가 들어 있다는 이유만으로 패키지 전체가 잘못됐다고 표시한 셈입니다.

점 하나가 경계 밖으로 바뀐 회귀

다른 회귀는 심볼릭 링크와도 직접 관련이 없었습니다. --target=.의 현재 디렉터리 표기가 정규화 과정에서 빈 상대 경로가 됐고, 새 검증기는 이를 목적지 밖으로 탈출한 디렉터리 항목처럼 처리했습니다. 이 문제는 패키지 종류에 국한되지 않았습니다. issue #21694의 보고자는 디렉터리 항목을 가진 대부분의 Wheel에서 재현됐다고 적었습니다.

공식 macOS arm64 바이너리로 이 범위를 다시 확인했습니다. SHA-256을 공식 체크섬과 대조한 뒤, 같은 Python 3.9.6과 iniconfig==2.0.0을 사용했습니다. uv 0.12.14에서 --target=.은 exit 2와 함께 Wheel directory entry escapes its destination 오류로 끝났고 설치 디렉터리는 생기지 않았습니다. 같은 버전에서 --target=packages와 절대 경로 target은 둘 다 exit 0이었습니다. uv 0.12.15에서 literal .도 exit 0으로 설치됐습니다.

같은 iniconfig 패키지를 target 점 상대 하위 디렉터리 절대 경로에 설치했을 때 uv 0.12.14와 0.12.15의 결과를 비교한 표

그림 2. 0.12.14는 literal 점 target만 실패했고, 0.12.15에서는 같은 명령이 성공했다. 출처: github.com/astral-sh/uv/issues/21694.

이 재현은 issue #21694의 좁은 주장만 확인합니다. Docker 안의 /usr/local/man 사례는 직접 실행하지 않았습니다. 그 부분은 공식 Python 이미지 여러 변형에서 같은 링크와 실패를 확인했다는 issue #21692, 그리고 해당 fixture를 성공 케이스로 바꾼 PR #21699의 테스트를 근거로 삼았습니다.

릴리스와 코드의 연결

PR이 병합됐다는 사실만으로 배포됐다고 볼 수는 없습니다. 원래 검사 PR #21569의 merge commit은 0.12.14 태그보다 49개 커밋 앞에 있고, GitHub compare 결과에서 태그가 그 커밋을 포함합니다. 되돌림 PR #21699의 merge commit도 0.12.15 태그에 포함되며, 태그는 그 뒤 3개 커밋을 더 가집니다. 두 릴리스의 공식 노트도 각각 9월 15일에 게시됐습니다.

0.12.15의 변경은 작은 보정이 아니라 원래 검증 구조의 철회에 가깝습니다. PR 파일 통계에서 wheel.rs의 194개 추가 줄이 제거됐고, 외부 링크를 거부하던 여러 회귀 테스트도 함께 빠졌습니다. 대신 --target=. 성공 테스트 30줄이 추가됐고, 설치 prefix 내부의 man -> share/man 링크는 허용하는 테스트로 바뀌었습니다.

여기서 "0.12.15가 안전하지 않다"고 단정하면 근거를 넘어섭니다. 공개 자료는 0.12.14의 방어 의도와 두 정상 설치 회귀, 그리고 되돌림을 보여 줍니다. 별도의 보안 권고나 악용 가능성 평가는 확인되지 않았습니다. 다만 0.12.15가 경계 안팎을 구분하는 새 검사를 완성한 버전도 아닙니다. 기존 동작으로 복귀한 버전입니다.

더 정밀한 경계는 어떻게 생겨야 하나

이 사례에서 필요한 질문은 "링크인가"가 아니라 "링크를 모두 해석한 최종 경로가 신뢰한 설치 root 안에 남는가"입니다. 먼저 .을 실제 현재 디렉터리로 정확히 정규화해야 합니다. 다음으로 각 링크의 최종 목적지를 계산하고, site-packages, data, headers 같은 신뢰한 scheme root 안팎을 판정해야 합니다. root 내부의 배포판 표준 링크는 허용할 수 있지만, 외부 임시 디렉터리로 이탈하는 fixture는 계속 막아야 합니다.

설치 target 정규화와 링크 최종 경로 계산 후 신뢰한 scheme root 안팎을 판정하는 정밀한 경계 검사 설계

그림 3. 정상 배포판 링크와 설치 루트 밖으로 나가는 링크를 나누려면 최종 해석 경로를 검사해야 한다. 출처: github.com/astral-sh/uv/pull/21699.

위 모델은 두 PR을 바탕으로 한 설계 해설이지, 0.12.15 구현을 설명하는 문장이 아닙니다. 실제 후속 구현이라면 정상 fixture와 공격 fixture를 같은 테스트 표에 넣어야 합니다. 보안 테스트만 두면 호환성 회귀가 숨어들고, 정상 설치 테스트만 두면 원래 막으려던 경계 이탈이 다시 들어올 수 있습니다.

운영자가 할 일

현재 0.12.14를 고정했다면 0.12.15 이상으로 올리는 편이 낫습니다. 특히 uv pip install --target=.을 쓰거나, 공식 Python Docker 이미지에서 --system 설치를 수행하며 man page가 든 패키지를 다룬다면 직접 영향 범위에 들어갑니다. 버전만 바꾼 뒤 끝내지 말고 CI에 literal ., 상대 하위 디렉터리, 절대 경로 target을 각각 넣어 두는 것이 좋습니다.

도구 개발자에게는 더 까다로운 과제가 남습니다. 파일 시스템 보안 검사는 문자열이나 경로 구성요소 하나로 끝나지 않습니다. 정규화 전후의 의미, 링크의 최종 목적지, 신뢰 root, 운영체제와 배포판의 정상 레이아웃을 함께 봐야 합니다. 이번 회귀는 방어를 추가하지 말라는 사례가 아닙니다. 방어가 지키려는 경계를 테스트가 정확히 표현해야 한다는 사례입니다.

참고 자료

댓글

이 블로그의 인기 게시물

체크아웃은 분리됐는데 Git 상태는 공유된다: Codex 0.154 worktree의 세 경계

잠근 작업이 커밋 뒤 다시 나온다: PostgreSQL 에이전트 큐의 소유권 설계

코딩 에이전트는 API 문서보다 AGENTS.md를 먼저 읽었다