패키지를 import 하지도 않았는데 그 패키지의 코드가 돌 수 있을까요? 돌 수 있어요. site-packages 에 놓인 .pth 파일의 import 로 시작하는 줄과 sitecustomize.py 는 파이썬이 시작할 때마다, 내 스크립트보다 먼저 실행돼요. python -c pass 한 줄이나 pip --version 도 예외가 아니에요. 그래서 이 자리는 정상 패키지가 초기화용으로 쓰기도 하고, 악성 릴리스가 자격 증명을 훔치는 코드를 심는 자리가 되기도 해요.
이 글은 이 "시작 훅"이 어디서 어떤 순서로 실행되는지, 어떤 실행 옵션이 무엇을 막는지, 그리고 내 환경과 설치할 휠에 수상한 훅이 있는지 그 환경의 파이썬을 켜지 않고 확인하는 방법을 다뤄요. 숫자는 맥의 Homebrew Python 3.13.7 가상 환경에서 직접 잰 값이고, 규칙은 Python 공식 문서와 PEP 829 를 따랐어요.
파이썬 시작 순서, 내 코드보다 먼저 도는 것
파이썬은 시작할 때 site 모듈을 자동으로 불러와요. Python site 모듈 문서에 적힌 순서를 정리하면 이래요.
- site-packages 디렉터리(사용자 디렉터리 포함)를 찾아
sys.path에 붙여요. - 각 디렉터리 안의
*.pth파일을 파일 이름 알파벳순으로 읽어요. 빈 줄과#줄은 건너뛰고,import뒤에 공백·탭이 오는 줄은exec()로 실행하고, 나머지 줄은 존재하는 경로일 때만sys.path에 추가해요. - 경로 처리가 끝나면
sitecustomize모듈을 import 해요. 없어서 생긴ImportError만 조용히 넘기고, 다른 예외는 프로세스를 "조용하고 알 수 없게" 실패시킬 수 있다고 문서가 적고 있어요. - 사용자 site 가 켜져 있으면
usercustomize모듈도 import 해요.
이 단계가 끝나야 내 스크립트, pip, pytest 가 실행돼요. 문서는 .pth 의 실행 줄을 "하위 호환을 위한" 기능이라고 부르는데, 실제로는 이 동작 덕분에 패키지 하나가 설치만으로 모든 파이썬 프로세스에 코드를 끼워 넣을 수 있어요.
import 판정은 줄 맨 앞에서 해요. 직접 해 보면 앞에 공백이 하나 붙은 ␣import sys 줄은 실행되지 않고 경로로 취급돼 버려지고, import 뒤에 탭을 둔 줄은 실행돼요. 한 줄에 세미콜론으로 여러 문장을 이어 쓸 수 있어서, 정상 패키지의 실행 줄도 대부분 한 줄짜리 긴 문장이에요.
실행 옵션별로 막히는 것: -I, -s, -S 차이
시작 훅을 줄이려고 격리 모드 -I 를 쓰는 경우가 많은데, -I 가 끄는 범위는 생각보다 좁아요. Python 명령행 옵션 문서는 -I 가 -E, -P, -s 를 함께 켠다고만 적어요. -S 는 포함되지 않아요.
| 옵션 | 하는 일 | 사용자 site 의 .pth·usercustomize | site-packages 의 .pth·sitecustomize |
|---|---|---|---|
| (없음) | 기본 | 실행 | 실행 |
-s 또는 PYTHONNOUSERSITE | 사용자 site-packages 를 sys.path 에 안 붙임 | 건너뜀 | 실행 |
-E | PYTHON* 환경 변수 무시 | 실행 | 실행 |
-P (3.11+) | 스크립트 폴더·현재 폴더를 sys.path 앞에 안 붙임 | 실행 | 실행 |
-I (3.4+) | -E + -P + -s | 건너뜀 | 실행 |
-S | site 모듈 자동 import 안 함 | 건너뜀 | 건너뜀 |
-S 는 강력한 만큼 부작용도 커요. site-packages 자체가 sys.path 에 붙지 않으니 설치한 서드파티 패키지를 import 할 수 없어요. 그래서 -S 는 표준 라이브러리만 쓰는 점검 스크립트를 돌릴 때 쓰는 옵션이지, 평소 실행 옵션으로 쓰기는 어려워요.
직접 확인하려면 가상 환경의 site-packages 에 메시지를 찍는 .pth 를 하나 넣고 옵션을 바꿔 가며 실행해 보면 돼요.
python3 -m venv /tmp/hooklab
SP=$(/tmp/hooklab/bin/python -c 'import site; print(site.getsitepackages()[0])')
printf 'import sys; print("pth 실행됨", file=sys.stderr)\n' > "$SP/zz_demo.pth"
/tmp/hooklab/bin/python -c pass # pth 실행됨
/tmp/hooklab/bin/python -I -c pass # pth 실행됨 ← 격리 모드여도 돌아요
/tmp/hooklab/bin/python -S -c pass # (출력 없음)
/tmp/hooklab/bin/python -m pip --version # pth 실행됨, 그다음 pip 버전
마지막 줄이 중요해요. pip 도 그 환경의 파이썬으로 돌기 때문에, 의심스러운 환경에서 pip list 나 pip uninstall 로 상태를 보려는 순간 시작 훅이 한 번 더 실행돼요.
.pth 시작 시간 실측, import 한 줄이 늘리는 지연
시작 훅은 보안 문제 이전에 성능 문제이기도 해요. .pth 의 실행 줄은 모든 파이썬 프로세스에서 돌기 때문에, 무거운 모듈을 import 하면 CLI 도구·테스트 워커·서브프로세스마다 그만큼 느려져요. 맥(Homebrew Python 3.13.7, 가상 환경)에서 python -c pass 를 80번 실행하고 앞 10번을 뺀 중앙값을 두 번 재서 평균했어요.
| 조건 | 기본 | -I | -S |
|---|---|---|---|
.pth 없음 | 116ms | 113ms | 83ms |
.pth 에 import json | 124ms | — | — |
.pth 에 import asyncio | 317ms | 317ms | 80ms |
절대값은 기계와 부하에 따라 달라요. 이 측정은 다른 작업이 돌던 맥에서 잰 것이라 빈 파이썬이 100ms 를 넘었어요. 봐야 할 것은 차이예요. import json 한 줄은 8ms 정도, import asyncio 한 줄은 약 200ms 를 더했어요. 테스트를 프로세스 100개로 나눠 돌리면 200ms × 100 = 20초가 시작에만 쓰이는 셈이에요.
느려진 원인이 시작 훅인지 의심되면 python -X importtime -c pass 2>&1 | sort -t'|' -k2 -n | tail 로 시작할 때 import 되는 모듈과 누적 시간을 볼 수 있어요. 내가 import 한 적 없는 모듈이 맨 아래에 크게 찍히면 .pth 나 sitecustomize 를 확인해 보세요.
정상 .pth 와 수상한 .pth 구별하는 표
.pth 가 있다는 것만으로 이상한 건 아니에요. 예를 들어 setuptools 휠에는 distutils-precedence.pth 가 들어 있고, 그 안의 한 줄은 import os; var = 'SETUPTOOLS_USE_DISTUTILS'; ... 로 시작하는 실행 줄이에요. setuptools 84.2.1 을 내려받아 직접 열어 확인한 내용이에요. 반대로 2026년 10월 LiteLLM 1.107.0 악성 릴리스는 cook.pth 에 import cook 한 줄을 넣어 같은 자리를 썼어요. 마이크로소프트 보안 블로그의 LiteLLM 1.107.0 분석에 따르면 휠의 litellm-1.107.0.data/purelib/ 아래에 cook.pth, cook.py, sitecustomize.py 를 넣어 설치 때 패키지 폴더 밖, site-packages 바로 아래로 옮겨지게 했어요.
이 경로가 가능한 이유는 휠 형식 자체에 있어요. PyPA 휠 형식 명세는 {배포명}-{버전}.data/purelib/ 아래 내용을 설치 때 purelib 위치, 즉 site-packages 로 옮기라고 정해요. 패키지 폴더 안만 보고 지우면 이 파일들이 남는다는 뜻이에요.
| 확인할 점 | 대체로 정상 | 의심 |
|---|---|---|
| 어느 패키지 소유인가 | RECORD 에 소유 패키지가 있고 이름이 그 패키지와 관련 있음 | 소유 패키지가 없음, 또는 이름이 패키지와 무관(cook.pth 가 LLM 프록시 소유) |
| 내용 | 경로 줄만 있음, 또는 그 패키지 모듈을 부르는 짧은 실행 줄 | 실행 줄이 처음 보는 모듈을 import, 인코딩된 긴 문자열, exec·compile·base64 |
sitecustomize.py | 배포판·Homebrew 같은 인터프리터 설치본이 둔 것 | 일반 PyPI 패키지가 설치한 것 |
| 설치 경로 | 패키지 폴더 안 | 휠의 .data/purelib/ 로 site-packages 루트에 놓임 |
sitecustomize.py 는 정상 환경에도 있을 수 있어요. 이 글을 쓴 맥의 Homebrew 파이썬도 인터프리터 폴더에 sitecustomize.py 를 두고 sys.path 순서를 정리해요. 그래서 "있다/없다"보다 누가 설치했는지가 판단 기준이에요.
설치된 환경 점검: 그 파이썬을 켜지 않고 확인하기
마이크로소프트 분석은 의심 환경 안에서 python·pip 를 실행하지 말고, 깨끗한 시스템의 신뢰할 수 있는 도구로 조사하라고 권해요. 위에서 본 것처럼 그 환경의 파이썬을 켜는 순간 훅이 돌기 때문이에요. 아래 스크립트는 다른 파이썬으로 -I -S 를 붙여 실행하고, 대상 site-packages 경로를 인자로 받아 파일만 읽어요. 각 .dist-info/RECORD 를 읽어 파일마다 소유 패키지도 붙여 줘요.
# scan_startup_hooks.py — 실행: python3 -I -S scan_startup_hooks.py <site-packages 경로>
import csv, pathlib, sys
HOOKS = {"sitecustomize.py", "usercustomize.py"}
def owners(site_dir):
table = {}
for record in site_dir.glob("*.dist-info/RECORD"):
dist = record.parent.name.split("-")[0]
with record.open(newline="", encoding="utf-8") as f:
for row in csv.reader(f):
if row:
table[(site_dir / row[0]).resolve()] = dist
return table
for arg in sys.argv[1:]:
site_dir = pathlib.Path(arg).resolve()
who = owners(site_dir)
for f in sorted(site_dir.iterdir()):
if not f.is_file():
continue
owner = who.get(f.resolve(), "RECORD 없음")
if f.suffix == ".pth":
text = f.read_text(encoding="utf-8", errors="replace")
runs = [l for l in text.splitlines() if l.startswith(("import ", "import\t"))]
print(f"[.pth] {f.name} ({owner}, {'실행 줄 있음' if runs else '경로만'})")
for line in runs:
print(" ", line[:100])
elif f.suffix == ".start":
print(f"[.start] {f.name} ({owner})")
elif f.name in HOOKS:
print(f"[훅] {f.name} ({owner})")
setuptools 를 설치한 가상 환경에 가짜 cook.pth·sitecustomize.py 를 손으로 넣고 돌린 결과예요.
[.pth] cook.pth (RECORD 없음, 실행 줄 있음)
import cook
[.pth] distutils-precedence.pth (setuptools, 실행 줄 있음)
import os; var = 'SETUPTOOLS_USE_DISTUTILS'; enabled = os.environ.get(var, 'local') == 'local'; enab
[훅] sitecustomize.py (RECORD 없음)
손으로 넣은 파일은 "RECORD 없음"으로 나오고, 악성 휠로 설치된 파일은 RECORD 에 그 패키지 이름이 찍혀요. 어느 쪽이든 실행 줄 내용과 소유 패키지를 함께 보면 판단이 빨라져요. 컨테이너 이미지라면 실행하지 말고 파일 시스템을 풀어서 같은 경로에 돌리면 돼요. 사용자 site(~/.local/lib/python3.x/site-packages)와 인터프리터 폴더의 sitecustomize.py 도 같이 보세요.
설치 전 휠 검사와 해시 고정
설치한 뒤에 찾는 것보다 설치 전에 막는 편이 싸요. 휠은 zip 파일이라 열어 보기만 하면 시작 훅이 들었는지 알 수 있어요. 아래 함수는 설치 뒤 site-packages 바로 아래에 놓일 .pth·.start·sitecustomize.py·usercustomize.py 를 찾아요. .data/purelib/·.data/platlib/ 아래 파일은 루트로 옮겨진다는 휠 규칙을 반영했어요.
# wheel_hooks.py — 실행: python3 -I -S wheel_hooks.py 패키지.whl
import sys, zipfile
def startup_files(wheel_path):
found = []
with zipfile.ZipFile(wheel_path) as whl:
for name in whl.namelist():
parts = name.split("/")
if len(parts) >= 3 and parts[0].endswith(".data") and parts[1] in ("purelib", "platlib"):
parts = parts[2:]
if len(parts) == 1 and (parts[0].endswith((".pth", ".start"))
or parts[0] in ("sitecustomize.py", "usercustomize.py")):
found.append(name)
return found
for path in sys.argv[1:]:
print(path, "→", startup_files(path) or "시작 훅 없음")
pip download --no-deps --only-binary :all: setuptools -d ./whl
python3 -I -S wheel_hooks.py ./whl/*.whl
# setuptools-84.2.1-py3-none-any.whl → ['distutils-precedence.pth']
# 직접 만든 가짜 휠 → ['demo-1.0.data/purelib/cook.pth', 'demo-1.0.data/purelib/sitecustomize.py']
--only-binary :all: 를 붙이는 이유는 소스 배포판(sdist)을 받으면 메타데이터를 만들려고 빌드 코드가 실행될 수 있어서예요. CI 에서는 새로 들어오거나 버전이 바뀐 휠에만 이 검사를 돌리고, 결과가 지난번과 달라졌을 때 사람이 보게 하면 부담이 적어요.
검사를 통과한 파일이 나중에 바뀌지 않게 하려면 해시를 고정해요. pip 보안 설치 문서에 따르면 요구사항 하나라도 --hash 가 있으면 모든 요구사항에 해시가 필요해지고, --require-hashes 로 이 모드를 강제할 수 있어요. 권장 알고리즘은 sha256 이고 md5·sha1 은 받지 않아요. 내려받은 파일의 SHA-256 을 공개된 값과 맞춰 볼 때는 해시 생성기로 짧은 문자열을 시험해 보거나, 파일은 shasum -a 256 으로 계산하면 돼요. 해시가 무엇을 보장하고 무엇을 보장하지 않는지는 MD5·SHA-256 차이 글에 정리해 두었어요.
해시 고정은 "내가 검사한 그 파일"만 설치되게 할 뿐, 처음부터 악성인 릴리스를 걸러 주지는 않아요. 그래서 순서가 중요해요. 새 버전을 들일 때 휠 검사 → 해시 기록 → 이후 설치는 해시로만, 이 흐름이 돼야 둘 다 의미가 있어요. 컨테이너도 latest 같은 움직이는 태그 대신 다이제스트로 고정하라는 것이 마이크로소프트 분석의 권고예요.
PEP 829 와 .start 파일, 앞으로 바뀌는 일정
.pth 의 실행 줄은 오래 비판받아 왔고, Python 3.15 에서 방향이 정해졌어요. PEP 829는 .pth 의 두 역할을 나눠요. 경로 추가는 .pth 에 그대로 두고, 시작할 때 실행할 코드는 새 <이름>.start 파일에 pkg.mod:callable 형식의 진입점으로 적게 해요. PEP 는 실행 줄에 대해 "코드 실행은 구현의 부작용"이라고 쓰고, 명시적인 진입점 개념이 없어서 패키지들이 import 줄을 남용한다고 설명해요.
PEP 와 3.15 site 모듈 문서의 규칙을 정리하면 이래요.
- 3.15·3.16·3.17:
.pth의 import 줄은 계속 실행돼요. 폐기 메시지는-v를 줄 때만 나와요. 단, 같은 이름의.start파일이 있으면 그.pth의 import 줄은 조용히 무시돼요. - 3.18·3.19: import 줄이 조용히 무시돼요.
- 3.20 이후: import 줄에 경고가 나와요.
.pth를 로캘 인코딩으로 읽는 지원도 제거돼요. - 모든
.pth의 경로 추가가 끝난 뒤에.start진입점이 호출되고, 같은 진입점이 두 번 적혀 있으면 두 번 불려요..start는 UTF-8 이어야 해요.
보안 관점에서 오해하면 안 되는 점이 있어요. .start 진입점도 시작할 때 코드를 실행해요. 바뀌는 건 실행 위치가 pkg.mod:callable 로 명시되어 찾기 쉬워진다는 점이고, sitecustomize 는 그대로예요. 그래서 위 점검 스크립트들이 .start 를 함께 찾도록 짰어요. 3.18 이 되어도 ".pth 실행 줄이 무시되니 안전하다"가 아니라 "시작 훅은 .start·sitecustomize·usercustomize 세 곳을 보면 된다"로 생각하면 맞아요.
상황별 점검 체크리스트
| 상황 | 할 일 |
|---|---|
| 공급망 사고 공지가 났고 해당 버전을 썼을 수 있음 | 그 환경에서 python·pip 실행 금지 → 다른 파이썬으로 -I -S 파일 점검 → 환경 재구축 → 닿을 수 있던 자격 증명 교체 |
| CI 에 새 의존성이 들어옴 | --only-binary :all: 로 받아 휠 검사 → 시작 훅이 있으면 사람 확인 → 해시 기록 |
| 파이썬 CLI·테스트가 이유 없이 느려짐 | -X importtime 으로 시작 import 확인 → .pth 실행 줄·sitecustomize 확인 |
| 점검·포렌식용 스크립트를 돌림 | 표준 라이브러리만 쓰고 -I -S 로 실행 |
| 사내 패키지가 시작 시 초기화가 필요함 | 3.15 이상을 지원한다면 .start 진입점으로 옮기고, 같은 이름의 .pth 에서 import 줄을 정리 |
자동화할 때는 결과를 그대로 실패 처리하기보다 허용 목록을 두는 편이 현실적이에요. distutils-precedence.pth 처럼 이미 확인한 파일은 소유 패키지와 내용 해시를 같이 기록해 두고, 목록에 없거나 내용이 달라진 시작 훅만 알리면 오탐이 줄어요.
자주 묻는 질문
.pth 파일을 모두 지워도 되나요?
안 돼요. 대부분의 .pth 는 경로 줄만 있거나, setuptools 처럼 그 패키지가 동작하는 데 필요한 초기화를 해요. 편집 가능 설치나 일부 패키지는 .pth 로 자기 경로를 sys.path 에 붙이기 때문에 지우면 import 가 깨질 수 있어요. 소유 패키지와 내용을 보고 수상한 것만 판단하세요.
python -I 로 실행하면 안전한 것 아닌가요?
-I 는 환경 변수, 사용자 site-packages, 현재 폴더를 sys.path 에서 빼서 바깥에서 끼어드는 코드를 줄여요. 하지만 일반 site-packages 의 .pth 와 sitecustomize 는 그대로 실행돼요. 실측에서도 .pth 에 넣은 import asyncio 지연이 -I 에서 그대로 남았어요. 시작 훅까지 끄려면 -S 를 써야 하고, 그 대신 서드파티 패키지를 쓸 수 없어요.
가상 환경을 쓰면 시스템 쪽 .pth 는 영향이 없나요?
가상 환경이 시스템 site-packages 를 포함하지 않게 만들어졌다면(기본값) 시스템 site-packages 의 .pth 는 읽지 않아요. 다만 가상 환경 자신의 site-packages 에 설치된 패키지의 .pth 는 똑같이 실행되고, 인터프리터 폴더의 sitecustomize.py 도 import 돼요. 가상 환경은 범위를 나눌 뿐 시작 훅을 없애지는 않아요.
이름이 점으로 시작하는 .pth 도 실행되나요?
직접 해 본 Python 3.13.7 에서는 .hidden.pth 처럼 점으로 시작하는 파일의 import 줄이 실행되지 않았어요. 버전마다 다를 수 있으니 점검 스크립트는 이름과 상관없이 모든 .pth 를 보는 편이 안전해요. 위 스크립트도 숨김 파일을 따로 거르지 않아요.
악성 패키지를 pip uninstall 로 지우면 끝인가요?
아니에요. pip uninstall 도 그 환경의 파이썬으로 돌기 때문에 지우는 순간 시작 훅이 한 번 더 실행돼요. 그리고 이미 실행된 코드가 읽어 간 자격 증명은 파일을 지워도 되돌릴 수 없어요. 검증된 버전으로 환경을 새로 만들고, 그 환경이 닿을 수 있던 키와 토큰을 깨끗한 기기에서 교체하는 것이 복구예요.
.start 파일은 언제부터 쓸 수 있나요?
PEP 829 는 Python 3.15 를 대상으로 확정됐고, 3.15 site 모듈 문서에 .start 처리가 들어 있어요. 3.14 이하를 함께 지원하는 패키지라면 당분간 .pth 의 import 줄을 유지해야 하고, 같은 이름의 .start 를 함께 두면 3.15 이상에서는 .start 쪽만 쓰이게 할 수 있어요.
정리
.pth의import줄,sitecustomize,usercustomize, 그리고 3.15 의.start진입점은 모두 내 코드보다 먼저, 모든 파이썬 프로세스에서 실행돼요.-I는 사용자 쪽만 끄고, 시작 훅 전체를 끄는 옵션은-S하나예요. 점검 스크립트는 다른 파이썬에서-I -S로 돌리세요.- 판단 기준은 "있다/없다"가 아니라 누가 설치했고 무엇을 실행하는가예요. RECORD 로 소유 패키지를 보고, 휠의
.data/purelib/경로를 설치 전에 확인하세요. - 새 의존성은 휠 검사 → 해시 기록 → 해시로만 설치 순서로 들이면, 검사한 파일이 그대로 배포돼요.
비밀번호·토큰을 교체해야 하는 상황이라면 비밀번호 생성기로 새 값을 만들 수 있어요. 토큰 만료 시간을 함께 다시 정할 때는 액세스·리프레시 토큰 만료 시간 글을 참고하세요.
더 깊이 읽기
이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 파이썬 JIT 제거·PHP2Shell·vLLM 도구 호출 — IT 뉴스에서 도식과 코드로 단계별로 풀었어요.