Wheel을 안 받아도 메타데이터는 검증해야 한다: uv 0.12.13의 PEP 658 해시

의존성 해석기는 패키지 본문보다 메타데이터를 먼저 본다

Python 패키지 해석기는 버전 번호만 보고 설치 대상을 고르지 않는다. Requires-Python과 Requires-Dist를 읽어 현재 Python에서 쓸 수 있는지, 어떤 하위 의존성이 더 필요한지 계산한다. 예전에는 wheel 안의 METADATA를 읽으려고 배포 파일 전체를 내려받거나 HTTP range 요청으로 ZIP 일부를 확인해야 했다.

PEP 658은 이 비용을 줄이는 규약이다. 패키지 인덱스가 wheel 옆에 파일명.whl.metadata를 따로 제공하고, 프로젝트 목록에는 그 파일이 있다는 표시와 선택적인 해시를 넣는다. 해석기는 작은 sidecar만 받아도 의존성 그래프를 만들 수 있다. 이때 sidecar는 wheel에 들어 있는 canonical metadata와 같아야 한다.

2026년 9월 10일 공개된 uv 0.12.13은 이 경로에서 빠졌던 검사를 추가했다. 인덱스가 sidecar 해시를 제공했는데도 uv가 그 값을 사용하지 않던 문제다. 작은 파일을 먼저 받는 최적화는 유지하되, 이제 새로 받은 bytes를 해시와 대조한 뒤에만 파싱하고 cache에 넣는다.

Python package index가 wheel URL과 PEP 658 metadata hash를 제공하고 uv 0.12.13이 sidecar bytes를 검증한 뒤 resolver와 cache에 전달하는 흐름

그림 1. uv 0.12.13은 별도 metadata를 받은 뒤 advertised digest를 계산하고, 일치할 때만 파싱·cache 저장한다. 출처: github.com/astral-sh/uv/pull/21563.

무결성 공백은 wheel 해시 바깥에 있었다

Simple Repository API에서 wheel 링크의 #sha256=...은 wheel 자체를 가리킨다. PEP 658 metadata는 별도 URL에서 내려오므로 별도 hash binding이 필요하다. 인덱스는 data-core-metadata="sha256=..." 또는 JSON 응답의 core-metadata 객체로 그 값을 전달할 수 있다.

uv PR #21563의 설명은 이전 동작을 명확히 적었다. uv는 인덱스가 알려 준 metadata hash를 무시했고, 내려받은 sidecar를 바로 해석했다. 그러면 전송 중 손상됐거나 인덱스가 약속한 bytes와 다른 파일도 문법만 맞으면 resolver 입력이 될 수 있었다. wheel 자체가 올바른 해시로 묶여 있어도, 해석 단계가 본 의존성 정보는 wheel 안의 내용과 달라질 수 있다.

이것을 곧바로 임의 코드 실행 취약점이라고 부르면 범위를 넘는다. 확인된 문제는 resolver가 신뢰하는 dependency metadata와 인덱스가 약속한 digest 사이의 검증 누락이다. 위험의 크기는 index 신뢰 경계, cache 상태, metadata가 실제 선택에 미친 영향에 따라 달라진다.

0.12.13은 파싱과 cache 저장 전에 막는다

고정된 0.12.13 source를 보면 순서가 보인다. uv는 .metadata 응답을 bytes로 받은 다음, 인덱스가 제공한 지원 digest를 하나씩 계산한다. 하나라도 다르면 MetadataHashMismatch를 반환한다. 그 뒤에야 metadata parser가 실행된다.

upstream test는 정상 metadata와 Summary: forged metadata 한 줄을 추가한 변형을 사용한다. 정상본 SHA-256은 1c9f…e00b, 변형본은 987a…1205다. 필자가 같은 문자열을 다시 계산해 두 값이 test fixture와 일치하는 것을 확인했다. test는 변형 sidecar가 fresh index뿐 아니라 cache된 index 응답에서도 거부되고, 거부된 bytes가 cache에 들어가지 않는지도 확인한다. 정상본으로 바꾸면 lock이 성공하고, 검증을 통과한 metadata는 다음 실행에서 다시 내려받지 않는다.

정상 metadata와 Summary forged metadata 한 줄이 추가된 변형본의 서로 다른 SHA-256과 match 또는 reject 결과 비교

그림 2. upstream test fixture의 두 SHA-256을 독립 재계산했으며, 변형 sidecar는 파싱과 cache 저장 전에 거부된다. 출처: github.com/astral-sh/uv/pull/21563.

같은 PR에는 PEP 714 호환 수정도 들어갔다. JSON Simple API가 새 core-metadata와 예전 dist-info-metadata를 함께 보낼 때, 표준은 새 key를 우선하라고 요구한다. 이전 parser는 JSON field 순서에 따라 legacy 값이 먼저 선택될 수 있었다. 0.12.13은 두 field를 따로 읽고 core-metadata가 있으면 그것을 먼저 사용한다. 무결성 검사를 넣어도 어느 hash field를 선택하는지가 흔들리면 결과가 안정적이지 않기 때문이다.

실제 PyPI에서는 4,806 bytes를 따로 받았다

현재 PyPI의 requests Simple API JSON을 읽어 한 wheel을 확인했다. requests-2.34.2-py3-none-any.whl은 73,075 bytes였고, 옆의 metadata sidecar는 4,806 bytes였다. 이 한 사례에서 wheel은 metadata보다 약 15.2배 컸다. 인덱스가 기록한 sidecar SHA-256 8c384b…56f27과 실제로 내려받아 계산한 값도 같았다.

이 수치는 uv 전체 설치가 15.2배 빨라진다는 뜻이 아니다. resolver는 여러 package와 version을 보고, cache hit와 network latency도 제각각이다. 다만 왜 PEP 658이 필요한지는 보여 준다. dependency 후보를 거르려고 73KB wheel을 전부 받는 대신 4.8KB metadata를 읽을 수 있다. 그리고 이번 수정은 그 빠른 경로를 포기하지 않고 bytes의 일치 여부를 확인한다.

requests 2.34.2 wheel 73075 bytes와 metadata sidecar 4806 bytes를 공통 0 기준으로 비교한 가로 막대

그림 3. 현재 PyPI의 한 artifact에서 wheel은 metadata보다 15.2배 컸고, sidecar SHA-256은 index 값과 일치했다. 출처: pypi.org/simple/requests.

해시 검사가 provenance를 만들어 주지는 않는다

이번 수정의 경계도 분명하다. 첫째, index가 hash 대신 true만 제공할 수 있다. 이 경우 sidecar가 있다는 사실은 알지만 비교할 digest는 없다. uv 코드도 빈 hash 목록이면 계산할 대상 없이 진행한다.

둘째, PR은 "새로 내려받은 sidecar"를 검사한다고 적는다. 기존 cache의 metadata를 소급해서 다시 hash 검증하지 않는다. upgrade했다는 사실만으로 과거 cache가 전부 재검증됐다고 보면 안 된다.

셋째, 공격자가 index 응답과 sidecar를 함께 바꾸고 새 digest까지 통제한다면 둘은 서로 일치한다. 이 검사는 index가 선언한 bytes와 받은 bytes가 같은지를 확인한다. 누가 그 metadata를 발행했는지, 원본 wheel이 신뢰할 publisher에게서 왔는지까지 증명하지는 않는다. TLS, index 운영 보안, wheel hash, 서명·attestation 같은 다른 층이 여전히 필요하다.

운영자가 확인할 것은 버전보다 입력 경계다

사내 mirror나 private index를 운영한다면 HTML과 JSON 응답에서 core-metadata hash가 실제로 제공되는지 먼저 본다. legacy key를 병행한다면 두 값이 같아야 하고, client가 새 key를 우선하는지도 확인해야 한다. index cache와 metadata cache를 따로 두는 구현이라면 expected digest가 cache 경계를 지나 보존되는지도 test할 만하다.

CI에서 uv를 올릴 때는 성공하는 install 하나만 보지 말고 negative control을 넣는 편이 낫다. index에는 정상 SHA-256을 두고 sidecar 한 줄을 바꾼 뒤, resolver가 parse 전에 실패하며 그 bytes를 cache하지 않는지 확인한다. 반대로 정상 sidecar는 한 번 검증된 뒤 재사용되는지 본다. PR #21563의 test가 바로 이 구조다.

이번 변경은 화려한 기능 추가가 아니다. package resolver가 "작은 metadata를 먼저 믿는 빠른 길"에서 빠뜨렸던 한 줄의 계약을 복원한 수정이다. 받은 파일이 parse 가능한지만 묻지 않고, index가 약속한 파일과 같은지도 묻는다.

참고 자료

댓글

이 블로그의 인기 게시물

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

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

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